From b1ce1927022d374198441afafeb27199fc8a052a Mon Sep 17 00:00:00 2001 From: eunhhu Date: Thu, 24 Sep 2026 17:14:54 +0100 Subject: Establish native micro runtime checkpoint --- prototypes/common/tls-link.c | 217 +++ prototypes/common/tls-link.h | 32 + prototypes/connections/.gitignore | 1 + prototypes/connections/Makefile | 251 ++++ prototypes/connections/README.md | 545 +++++++ prototypes/connections/SOURCE_PREVIEW.md | 119 ++ prototypes/connections/async-core-budget.json | 16 + prototypes/connections/async-server-budget.json | 12 + prototypes/connections/check.py | 552 +++++++ prototypes/connections/check_async_server.py | 181 +++ prototypes/connections/check_copy_path.py | 100 ++ prototypes/connections/check_core.py | 74 + prototypes/connections/check_document_peer.py | 273 ++++ .../connections/check_document_server_package.py | 100 ++ prototypes/connections/check_edits.py | 108 ++ prototypes/connections/check_fixed_program.py | 159 ++ prototypes/connections/check_fixed_server.py | 180 +++ prototypes/connections/check_flat_export.py | 134 ++ prototypes/connections/check_idle_wakes.py | 95 ++ prototypes/connections/check_micro_memory.py | 106 ++ prototypes/connections/check_mirror.py | 137 ++ prototypes/connections/check_package.py | 209 +++ prototypes/connections/check_policy.py | 188 +++ prototypes/connections/check_profile.py | 82 ++ prototypes/connections/check_service_scale.py | 131 ++ prototypes/connections/check_small_scan.py | 54 + prototypes/connections/check_source_package.py | 145 ++ prototypes/connections/check_tui.py | 352 +++++ prototypes/connections/check_window.py | 553 +++++++ prototypes/connections/check_write_cost.py | 93 ++ prototypes/connections/compile-program.c | 116 ++ prototypes/connections/core-budget.json | 15 + prototypes/connections/core-layout.c | 14 + prototypes/connections/document-peer.c | 330 +++++ prototypes/connections/edit-tui.c | 945 ++++++++++++ prototypes/connections/edit-window.c | 638 ++++++++ prototypes/connections/edit.c | 177 +++ prototypes/connections/evaluate.c | 53 + .../connections/examples/bounded-editor.pure | 19 + prototypes/connections/examples/editor.pure | 19 + prototypes/connections/examples/excerpt.pure | 13 + prototypes/connections/examples/select-text.py | 72 + prototypes/connections/examples/selection.pure | 19 + prototypes/connections/examples/storage.pure | 11 + prototypes/connections/export_flat_store.py | 143 ++ prototypes/connections/fixed-budget.json | 18 + prototypes/connections/fixed-package-budget.json | 10 + prototypes/connections/flow.c | 41 + prototypes/connections/flow.h | 25 + prototypes/connections/hybrid-budget.json | 18 + prototypes/connections/hybrid-tui-budget.json | 12 + prototypes/connections/measure-peak-rss.c | 43 + prototypes/connections/measure_tui_latency.py | 101 ++ prototypes/connections/memory-budget.json | 7 + prototypes/connections/mirror.c | 145 ++ prototypes/connections/package_native.py | 361 +++++ prototypes/connections/package_source.py | 67 + prototypes/connections/paged-budget.json | 18 + prototypes/connections/peer-budget.json | 12 + prototypes/connections/peer-package-budget.json | 8 + prototypes/connections/peer-server-budget.json | 12 + prototypes/connections/profile-budget.json | 18 + prototypes/connections/program.c | 374 +++++ prototypes/connections/program.h | 50 + prototypes/connections/result.h | 8 + .../results/2026-09-23-byte-storage/README.md | 31 + .../2026-09-23-byte-storage/initial/native.json | 116 ++ .../2026-09-23-byte-storage/initial/sanitized.json | 116 ++ .../2026-09-23-byte-storage/initial/test-flow.c | 178 +++ .../results/2026-09-23-byte-storage/native.json | 116 ++ .../results/2026-09-23-byte-storage/sanitized.json | 116 ++ .../sources/prototypes/connections/Makefile | 20 + .../sources/prototypes/connections/check.py | 317 ++++ .../sources/prototypes/connections/flow.c | 41 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/server.c | 347 +++++ .../sources/prototypes/connections/store.c | 225 +++ .../sources/prototypes/connections/store.h | 34 + .../sources/prototypes/connections/test-flow.c | 183 +++ .../sources/prototypes/workbench/cli.mjs | 127 ++ .../prototypes/workbench/examples/direct.pure | 47 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61072 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../2026-09-23-byte-storage/verification.json | 52 + .../results/2026-09-23-document-edits/README.md | 27 + .../2026-09-23-document-edits/boundary-checks.json | 34 + .../results/2026-09-23-document-edits/core.json | 53 + .../results/2026-09-23-document-edits/native.json | 635 ++++++++ .../2026-09-23-document-edits/sanitized.json | 635 ++++++++ .../sources/prototypes/connections/Makefile | 59 + .../sources/prototypes/connections/check.py | 551 +++++++ .../sources/prototypes/connections/check_core.py | 74 + .../sources/prototypes/connections/check_edits.py | 108 ++ .../prototypes/connections/core-budget.json | 15 + .../sources/prototypes/connections/core-layout.c | 11 + .../sources/prototypes/connections/edit.c | 153 ++ .../sources/prototypes/connections/evaluate.c | 53 + .../prototypes/connections/examples/editor.pure | 19 + .../prototypes/connections/examples/excerpt.pure | 13 + .../prototypes/connections/examples/select-text.py | 72 + .../prototypes/connections/examples/selection.pure | 19 + .../prototypes/connections/examples/storage.pure | 11 + .../sources/prototypes/connections/flow.c | 41 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/program.c | 366 +++++ .../sources/prototypes/connections/program.h | 44 + .../sources/prototypes/connections/result.h | 8 + .../sources/prototypes/connections/runtime.c | 273 ++++ .../sources/prototypes/connections/runtime.h | 88 ++ .../sources/prototypes/connections/server.c | 372 +++++ .../sources/prototypes/connections/store-port.c | 44 + .../sources/prototypes/connections/store-port.h | 6 + .../sources/prototypes/connections/store.c | 258 ++++ .../sources/prototypes/connections/store.h | 44 + .../sources/prototypes/connections/test-fields.c | 153 ++ .../sources/prototypes/connections/test-flow.c | 183 +++ .../sources/prototypes/connections/test-program.c | 111 ++ .../sources/prototypes/connections/test-runtime.c | 285 ++++ .../sources/prototypes/connections/test-splice.c | 172 +++ .../sources/prototypes/workbench/cli.mjs | 127 ++ .../prototypes/workbench/examples/direct.pure | 47 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61072 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../2026-09-23-document-edits/verification.json | 32 + .../results/2026-09-23-execution-core/README.md | 28 + .../results/2026-09-23-execution-core/native.json | 505 +++++++ .../2026-09-23-execution-core/sanitized.json | 505 +++++++ .../sources/prototypes/connections/Makefile | 43 + .../sources/prototypes/connections/check.py | 536 +++++++ .../sources/prototypes/connections/evaluate.c | 48 + .../prototypes/connections/examples/excerpt.pure | 13 + .../prototypes/connections/examples/select-text.py | 72 + .../prototypes/connections/examples/selection.pure | 19 + .../prototypes/connections/examples/storage.pure | 11 + .../sources/prototypes/connections/flow.c | 41 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/program.c | 357 +++++ .../sources/prototypes/connections/program.h | 39 + .../sources/prototypes/connections/result.h | 8 + .../sources/prototypes/connections/runtime.c | 265 ++++ .../sources/prototypes/connections/runtime.h | 86 ++ .../sources/prototypes/connections/server.c | 368 +++++ .../sources/prototypes/connections/store-port.c | 39 + .../sources/prototypes/connections/store-port.h | 6 + .../sources/prototypes/connections/store.c | 228 +++ .../sources/prototypes/connections/store.h | 37 + .../sources/prototypes/connections/test-fields.c | 153 ++ .../sources/prototypes/connections/test-flow.c | 183 +++ .../sources/prototypes/connections/test-program.c | 111 ++ .../sources/prototypes/connections/test-runtime.c | 285 ++++ .../sources/prototypes/workbench/cli.mjs | 127 ++ .../prototypes/workbench/examples/direct.pure | 47 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61072 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../2026-09-23-execution-core/verification.json | 30 + .../results/2026-09-23-field-connections/README.md | 31 + .../2026-09-23-field-connections/native.json | 488 +++++++ .../2026-09-23-field-connections/sanitized.json | 488 +++++++ .../sources/prototypes/connections/Makefile | 28 + .../sources/prototypes/connections/check.py | 528 +++++++ .../prototypes/connections/examples/excerpt.pure | 13 + .../prototypes/connections/examples/select-text.py | 72 + .../prototypes/connections/examples/selection.pure | 19 + .../prototypes/connections/examples/storage.pure | 11 + .../sources/prototypes/connections/flow.c | 41 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/program.c | 402 +++++ .../sources/prototypes/connections/program.h | 39 + .../sources/prototypes/connections/server.c | 393 +++++ .../sources/prototypes/connections/store.c | 228 +++ .../sources/prototypes/connections/store.h | 40 + .../sources/prototypes/connections/test-fields.c | 153 ++ .../sources/prototypes/connections/test-flow.c | 183 +++ .../sources/prototypes/connections/test-program.c | 111 ++ .../sources/prototypes/workbench/cli.mjs | 127 ++ .../prototypes/workbench/examples/direct.pure | 47 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61072 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../2026-09-23-field-connections/verification.json | 62 + .../2026-09-23-native-connections/README.md | 26 + .../2026-09-23-native-connections/native.json | 98 ++ .../2026-09-23-native-connections/sanitized.json | 98 ++ .../sources/prototypes/connections/Makefile | 22 + .../sources/prototypes/connections/check.py | 306 ++++ .../sources/prototypes/connections/flow.c | 41 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/server.c | 346 +++++ .../sources/prototypes/connections/store.c | 132 ++ .../sources/prototypes/connections/store.h | 25 + .../sources/prototypes/connections/test-flow.c | 110 ++ .../sources/prototypes/workbench/cli.mjs | 127 ++ .../sources/prototypes/workbench/cli.test.mjs | 121 ++ .../prototypes/workbench/examples/direct.pure | 47 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61072 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../verification.json | 29 + .../2026-09-23-prepared-connections/README.md | 31 + .../2026-09-23-prepared-connections/native.json | 269 ++++ .../2026-09-23-prepared-connections/sanitized.json | 269 ++++ .../sources/prototypes/connections/Makefile | 24 + .../sources/prototypes/connections/check.py | 419 ++++++ .../prototypes/connections/examples/excerpt.pure | 13 + .../prototypes/connections/examples/storage.pure | 11 + .../sources/prototypes/connections/flow.c | 41 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/program.c | 233 +++ .../sources/prototypes/connections/program.h | 26 + .../sources/prototypes/connections/server.c | 385 +++++ .../sources/prototypes/connections/store.c | 227 +++ .../sources/prototypes/connections/store.h | 39 + .../sources/prototypes/connections/test-flow.c | 183 +++ .../sources/prototypes/connections/test-program.c | 111 ++ .../sources/prototypes/workbench/cli.mjs | 127 ++ .../prototypes/workbench/examples/direct.pure | 47 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61072 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../verification.json | 51 + .../2026-09-24-async-effects/async-core.json | 55 + .../2026-09-24-async-effects/default-core.json | 53 + .../results/2026-09-24-async-effects/flow.json | 1 + .../2026-09-24-async-effects/sanitized.json | 1 + .../results/2026-09-24-async-http/full-check.json | 614 ++++++++ .../results/2026-09-24-async-http/gated.json | 38 + .../results/2026-09-24-async-http/profile.json | 21 + .../results/2026-09-24-async-http/sanitized.json | 38 + .../service-scale-memory.json | 100 ++ .../2026-09-24-async-read-write/full-check.json | 614 ++++++++ .../results/2026-09-24-async-read-write/gated.json | 53 + .../2026-09-24-async-read-write/profile.json | 21 + .../2026-09-24-async-read-write/sanitized.json | 53 + .../2026-09-24-async-read-write/service-scale.json | 100 ++ .../2026-09-24-atomic-policy/policy-peer.json | 12 + .../results/2026-09-24-atomic-policy/policy.json | 11 + .../pure-document-fixed-cli-linux-aarch64.tar.gz | Bin 0 -> 12966 bytes ...ument-fixed-cli-linux-aarch64.tar.gz.check.json | 12 + ...ure-document-fixed-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 32144 bytes ...t-fixed-cli-tui-linux-aarch64.tar.gz.check.json | 14 + .../pure-document-source-cli-linux-aarch64.tar.gz | Bin 0 -> 22862 bytes ...ment-source-cli-linux-aarch64.tar.gz.check.json | 12 + ...re-document-source-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 51398 bytes ...-source-cli-tui-linux-aarch64.tar.gz.check.json | 14 + .../pure-source-preview.tar.gz | Bin 0 -> 337937 bytes .../source-package-check.json | 16 + .../2026-09-24-compact-runtime/cli-check.json | 12 + .../2026-09-24-compact-runtime/cli-tui-check.json | 14 + .../2026-09-24-compact-runtime/cli-tui.tar.gz | Bin 0 -> 31971 bytes .../results/2026-09-24-compact-runtime/cli.tar.gz | Bin 0 -> 12885 bytes .../results/2026-09-24-compact-runtime/core.json | 53 + .../default-profile.json | 32 + .../2026-09-24-compact-runtime/fixed-memory.json | 15 + .../2026-09-24-compact-runtime/fixed-profile.json | 32 + .../source-cli-check.json | 12 + .../2026-09-24-compact-runtime/source-cli.tar.gz | Bin 0 -> 22454 bytes .../2026-09-24-compact-runtime/tui-profile.json | 21 + .../results/2026-09-24-compact-runtime/tui.json | 22 + .../pure-source-preview.tar.gz | Bin 0 -> 324261 bytes .../source-check-linux-aarch64.json | 16 + .../results/2026-09-24-copy-range/copy-path.json | 27 + .../2026-09-24-copy-range/default-core.json | 53 + .../default-fixed-memory.json | 15 + .../default-fixed-profile.json | 32 + .../2026-09-24-copy-range/default-functional.json | 636 ++++++++ .../2026-09-24-copy-range/default-memory.json | 15 + .../2026-09-24-copy-range/default-profile.json | 32 + .../results/2026-09-24-copy-range/functional.json | 636 ++++++++ .../results/2026-09-24-copy-range/memory.json | 15 + .../results/2026-09-24-copy-range/profile.json | 32 + .../2026-09-24-copy-range/service-scale.json | 70 + .../2026-09-24-document-peer/clean-peer.log | 43 + .../2026-09-24-document-peer/clean-release.log | 737 ++++++++++ .../results/2026-09-24-document-peer/dynamic.txt | 31 + .../2026-09-24-document-peer/file-sizes.txt | 2 + .../results/2026-09-24-document-peer/hashes.txt | 4 + .../2026-09-24-document-peer/package-check.json | 19 + .../2026-09-24-document-peer/package-hashes.txt | 3 + .../2026-09-24-document-peer/peer-budget.json | 23 + .../2026-09-24-document-peer/peer-flow.json | 16 + .../2026-09-24-document-peer/peer-sanitized.json | 16 + .../pure-document-linux-aarch64.tar.gz | Bin 0 -> 316927 bytes .../2026-09-24-document-peer/scalar-refactor.json | 1 + .../results/2026-09-24-document-peer/size.txt | 2 + .../2026-09-24-fixed-http/backpressure.json | 61 + .../results/2026-09-24-fixed-http/final.json | 65 + .../2026-09-24-fixed-http/fixed-profile.json | 32 + .../results/2026-09-24-fixed-http/package.json | 14 + .../results/2026-09-24-fixed-http/parity.json | 43 + .../results/2026-09-24-fixed-http/peak-memory.json | 67 + .../pure-document-fixed-http-linux-aarch64.tar.gz | Bin 0 -> 31733 bytes .../2026-09-24-fixed-http/source-profile.json | 32 + .../results/2026-09-24-fixed-http/verified.json | 68 + .../2026-09-24-fixed-program/memory-final.json | 15 + .../2026-09-24-fixed-program/memory-verified.json | 15 + .../results/2026-09-24-fixed-program/memory.json | 15 + .../2026-09-24-fixed-program/package-final.json | 12 + .../results/2026-09-24-fixed-program/package.json | 12 + .../2026-09-24-fixed-program/parity-final-v2.json | 12 + .../2026-09-24-fixed-program/parity-final.json | 11 + .../parity-reconfigured.json | 13 + .../2026-09-24-fixed-program/parity-release.json | 14 + .../2026-09-24-fixed-program/parity-verified.json | 12 + .../results/2026-09-24-fixed-program/parity.json | 10 + .../2026-09-24-fixed-program/profile-final.json | 21 + .../results/2026-09-24-fixed-program/profile.json | 21 + .../pure-document-fixed-final-linux-aarch64.tar.gz | Bin 0 -> 14385 bytes .../pure-document-fixed-linux-aarch64.tar.gz | Bin 0 -> 14386 bytes .../window-regression.json | 1 + .../2026-09-24-fixed-tui-final/package.json | 14 + .../2026-09-24-fixed-tui-final/profile.json | 21 + .../2026-09-24-fixed-tui-final/pty-flow.json | 16 + .../2026-09-24-fixed-tui-final/pty-sanitized.json | 16 + .../pure-document-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 33654 bytes .../2026-09-24-fixed-tui/package-cursor.json | 14 + .../results/2026-09-24-fixed-tui/package.json | 14 + .../results/2026-09-24-fixed-tui/profile.json | 21 + .../2026-09-24-fixed-tui/pty-cursor-flow.json | 16 + .../2026-09-24-fixed-tui/pty-cursor-sanitized.json | 16 + .../results/2026-09-24-fixed-tui/pty-flow.json | 16 + .../2026-09-24-fixed-tui/pty-sanitized.json | 16 + .../pure-document-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 33625 bytes .../pure-source-preview.tar.gz | Bin 0 -> 329118 bytes .../source-check-linux-aarch64.json | 14 + .../2026-09-24-flat-export/export-check.json | 27 + .../2026-09-24-flat-export/export-ext4.json | 28 + .../2026-09-24-flat-export/export-scale-1024.json | 27 + .../2026-09-24-hybrid-storage-v2/app-asan.json | 635 ++++++++ .../2026-09-24-hybrid-storage-v2/app-release.json | 635 ++++++++ .../2026-09-24-hybrid-storage-v2/gtk-flow.json | 1 + .../2026-09-24-hybrid-storage-v2/memory.json | 14 + .../2026-09-24-hybrid-storage-v2/package-full.json | 21 + .../package-micro.json | 13 + .../results/2026-09-24-hybrid-storage-v2/peer.json | 16 + .../2026-09-24-hybrid-storage-v2/profile.json | 32 + ...-document-hybrid-authoring-linux-aarch64.tar.gz | Bin 0 -> 114025 bytes ...pure-document-hybrid-micro-linux-aarch64.tar.gz | Bin 0 -> 26045 bytes .../2026-09-24-hybrid-storage-v2/small-scan.json | 5 + .../2026-09-24-hybrid-storage-v2/unit-asan.json | 1 + .../results/2026-09-24-hybrid-storage-v2/unit.json | 1 + .../2026-09-24-hybrid-storage-v2/write-cost.json | 49 + .../2026-09-24-hybrid-storage/app-asan.json | 635 ++++++++ .../2026-09-24-hybrid-storage/app-release.json | 635 ++++++++ .../2026-09-24-hybrid-storage/gtk-flow.json | 1 + .../results/2026-09-24-hybrid-storage/memory.json | 14 + .../2026-09-24-hybrid-storage/package-full.json | 21 + .../2026-09-24-hybrid-storage/package-micro.json | 13 + .../results/2026-09-24-hybrid-storage/peer.json | 16 + .../results/2026-09-24-hybrid-storage/profile.json | 32 + ...-document-hybrid-authoring-linux-aarch64.tar.gz | Bin 0 -> 114009 bytes ...pure-document-hybrid-micro-linux-aarch64.tar.gz | Bin 0 -> 26040 bytes .../2026-09-24-hybrid-storage/unit-asan.json | 1 + .../results/2026-09-24-hybrid-storage/unit.json | 1 + .../2026-09-24-hybrid-storage/write-cost.json | 49 + .../2026-09-24-idle-wake/archived-comparison.json | 21 + .../2026-09-24-idle-wake/fixed-profile.json | 32 + .../fixed-server-regression.json | 68 + .../results/2026-09-24-idle-wake/idle.json | 21 + .../results/2026-09-24-idle-wake/package.json | 14 + ...e-document-fixed-http-idle-linux-aarch64.tar.gz | Bin 0 -> 31773 bytes .../reproducible-comparison.json | 21 + .../2026-09-24-idle-wake/source-profile.json | 32 + .../2026-09-24-idle-work-binding/async-core.json | 55 + .../2026-09-24-idle-work-binding/async-gated.json | 53 + .../async-server-profile.json | 21 + .../2026-09-24-idle-work-binding/cli-check.json | 12 + .../cli-http-check.json | 14 + .../cli-http-tui-check.json | 16 + .../cli-http-tui.tar.gz | Bin 0 -> 47419 bytes .../2026-09-24-idle-work-binding/cli-http.tar.gz | Bin 0 -> 28719 bytes .../cli-tui-check.json | 14 + .../2026-09-24-idle-work-binding/cli-tui.tar.gz | Bin 0 -> 31726 bytes .../2026-09-24-idle-work-binding/cli.tar.gz | Bin 0 -> 12987 bytes .../results/2026-09-24-idle-work-binding/core.json | 53 + .../default-profile.json | 32 + .../document-peer-profile.json | 23 + .../document-peer.json | 16 + .../2026-09-24-idle-work-binding/fixed-memory.json | 15 + .../fixed-profile.json | 32 + .../2026-09-24-idle-work-binding/full-check.json | 636 ++++++++ .../package-reproducibility.json | 31 + .../precompact-budget-failure.txt | 9 + .../2026-09-24-idle-work-binding/release.log | 800 ++++++++++ .../runtime-sanitized.json | 1 + .../service-scale.json | 100 ++ .../source-cli-check.json | 12 + .../2026-09-24-idle-work-binding/source-cli.tar.gz | Bin 0 -> 22595 bytes .../source-memory.json | 15 + .../2026-09-24-idle-work-binding/tui-profile.json | 21 + .../results/2026-09-24-idle-work-binding/tui.json | 22 + .../2026-09-24-in-process-routing/mirror-ext4.json | 56 + .../mirror-sanitized.json | 46 + .../2026-09-24-in-process-routing/mirror-tmp.json | 56 + .../pure-source-preview.tar.gz | Bin 0 -> 338097 bytes .../source-package-check.json | 14 + .../cli-check.json | 12 + .../2026-09-24-micro-package-boundary/cli.tar.gz | Bin 0 -> 14692 bytes .../cli_http-check.json | 14 + .../cli_http.tar.gz | Bin 0 -> 32548 bytes .../cli_http_tui-check.json | 16 + .../cli_http_tui.tar.gz | Bin 0 -> 51424 bytes .../cli_tui-check.json | 14 + .../cli_tui.tar.gz | Bin 0 -> 33761 bytes .../default-host-budget.json | 32 + .../pure-source-preview.tar.gz | Bin 0 -> 332792 bytes .../source-check-linux-aarch64.json | 15 + .../gtk-asan-final.json | 1 + .../gtk-asan-repeated.json | 1 + .../2026-09-24-multi-document-window/gtk-asan.json | 1 + .../gtk-flow-final.json | 1 + .../gtk-flow-repeated.json | 1 + .../2026-09-24-multi-document-window/gtk-flow.json | 1 + .../hybrid-gtk-flow.json | 1 + .../legacy-package-recheck.json | 14 + .../legacy-package-repeated.json | 14 + .../package-final.json | 14 + .../package-repeated.json | 14 + .../2026-09-24-multi-document-window/package.json | 14 + ...pure-document-window-final-linux-aarch64.tar.gz | Bin 0 -> 49746 bytes .../pure-document-window-linux-aarch64.tar.gz | Bin 0 -> 49722 bytes .../switch-memory-fresh-buffer.json | 1 + .../switch-memory-initial.json | 1 + .../window-profile.json | 25 + .../2026-09-24-native-checkpoint/fixed-memory.json | 43 + .../2026-09-24-native-checkpoint/fixed-tui.json | 24 + .../source-memory.json | 43 + .../2026-09-24-native-checkpoint/source-tui.json | 27 + .../full-package-check.json | 20 + .../gtk-flow-final.json | 1 + .../gtk-flow.json | 1 + .../gtk-sanitized-final.json | 1 + .../gtk-sanitized.json | 1 + .../http-only-package-check.json | 14 + .../micro-memory.json | 14 + .../micro-package-check.json | 12 + .../peer-only-package-check.json | 16 + .../pure-document-authoring-linux-aarch64.tar.gz | Bin 0 -> 100900 bytes .../pure-document-http-only-linux-aarch64.tar.gz | Bin 0 -> 47527 bytes .../pure-document-micro-linux-aarch64.tar.gz | Bin 0 -> 22482 bytes .../pure-document-peer-only-linux-aarch64.tar.gz | Bin 0 -> 49186 bytes .../pure-document-window-only-linux-aarch64.tar.gz | Bin 0 -> 48934 bytes .../window-authoring.png | Bin 0 -> 76178 bytes .../window-budget.json | 25 + .../window-only-package-check.json | 14 + .../2026-09-24-native-document-window/build.json | 16 + .../connection-check.json | 635 ++++++++ .../core-budget.json | 53 + .../dependencies.txt | 10 + .../gtk-flow.json | 1 + .../gtk-sanitized.json | 1 + .../2026-09-24-native-document-window/size.txt | 6 + .../2026-09-24-native-document-window/stripped.txt | 3 + .../2026-09-24-native-document-window/window.png | Bin 0 -> 23918 bytes .../fixed-cli-package-check.json | 12 + .../fixed-package-check.json | 14 + .../2026-09-24-native-save/flat-fixed-tui.json | 24 + .../2026-09-24-native-save/flat-package-check.json | 14 + .../2026-09-24-native-save/flat-source-tui.json | 27 + .../2026-09-24-native-save/hybrid-fixed-tui.json | 24 + .../2026-09-24-native-save/hybrid-source-tui.json | 27 + .../2026-09-24-native-save/package-check.json | 15 + .../pure-document-fixed-cli-linux-aarch64.tar.gz | Bin 0 -> 12966 bytes ...ure-document-fixed-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 32072 bytes ...pure-document-flat-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 51117 bytes ...re-document-hybrid-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 57788 bytes .../pure-source-preview.tar.gz | Bin 0 -> 333925 bytes .../source-package-check.json | 16 + .../pure-source-preview.tar.gz | Bin 0 -> 325436 bytes .../source-check-linux-aarch64.json | 13 + .../pure-document-fixed-cli-linux-aarch64.tar.gz | Bin 0 -> 12886 bytes ...ument-fixed-cli-linux-aarch64.tar.gz.check.json | 12 + ...ure-document-fixed-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 31965 bytes ...t-fixed-cli-tui-linux-aarch64.tar.gz.check.json | 14 + .../pure-document-source-cli-linux-aarch64.tar.gz | Bin 0 -> 22655 bytes ...ment-source-cli-linux-aarch64.tar.gz.check.json | 12 + ...re-document-source-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 51005 bytes ...-source-cli-tui-linux-aarch64.tar.gz.check.json | 14 + .../pure-source-preview.tar.gz | Bin 0 -> 334682 bytes .../source-package-check.json | 15 + .../results/2026-09-24-paged-storage/app-asan.json | 635 ++++++++ .../2026-09-24-paged-storage/app-release.json | 635 ++++++++ .../results/2026-09-24-paged-storage/gtk-flow.json | 1 + .../results/2026-09-24-paged-storage/memory.json | 14 + .../2026-09-24-paged-storage/package-full.json | 20 + .../2026-09-24-paged-storage/package-micro.json | 12 + .../2026-09-24-paged-storage/paged-profile.json | 32 + .../results/2026-09-24-paged-storage/peer.json | 16 + ...e-document-paged-authoring-linux-aarch64.tar.gz | Bin 0 -> 111765 bytes .../pure-document-paged-micro-linux-aarch64.tar.gz | Bin 0 -> 25456 bytes .../2026-09-24-paged-storage/unit-asan.json | 1 + .../results/2026-09-24-paged-storage/unit.json | 1 + .../2026-09-24-paged-storage/write-cost.json | 49 + .../pure-source-preview.tar.gz | Bin 0 -> 321958 bytes .../source-check-linux-aarch64.json | 15 + .../client-server-linux-aarch64.json | 17 + .../client-server-linux-rss-regression.json | 17 + .../client-server-profile.json | 23 + .../server-only-linux-aarch64.json | 17 + .../server-only-linux-rss-regression.json | 17 + .../server-only-profile.json | 23 + .../pure-source-preview.tar.gz | Bin 0 -> 321399 bytes .../source-check-linux-aarch64.json | 14 + .../fixed-linux-aarch64.json | 24 + .../fixed-linux-rss-regression.json | 24 + .../source-linux-aarch64.json | 27 + .../source-linux-rss-regression.json | 27 + .../2026-09-24-posix-idle/fixed-profile.json | 32 + .../fixed-server-regression.json | 68 + .../results/2026-09-24-posix-idle/package.json | 14 + .../pure-document-fixed-http-linux-aarch64.tar.gz | Bin 0 -> 32346 bytes .../reproducible-comparison.json | 21 + .../2026-09-24-posix-idle/service-scale.json | 62 + .../2026-09-24-posix-idle/source-profile.json | 32 + .../results/2026-09-24-prefix-find/cli-tui.tar.gz | Bin 0 -> 32233 bytes .../2026-09-24-prefix-find/package-check.json | 14 + .../2026-09-24-prefix-find/tui-profile.json | 21 + .../results/2026-09-24-prefix-find/tui.json | 22 + .../2026-09-24-release-callback/async-core.json | 55 + .../results/2026-09-24-release-callback/core.json | 53 + .../micro-package-check.json | 12 + .../pure-document-micro-linux-aarch64.tar.gz | Bin 0 -> 22758 bytes .../pure-source-preview.tar.gz | Bin 0 -> 333037 bytes .../2026-09-24-release-callback/runtime.json | 1 + .../source-package-check.json | 15 + .../combined-peer-package-check.json | 27 + ...ure-document-combined-peer-linux-aarch64.tar.gz | Bin 0 -> 49382 bytes .../pure-document-server-only-linux-aarch64.tar.gz | Bin 0 -> 47686 bytes .../server-only-package-check.json | 23 + .../2026-09-24-signal-safe-idle/fixed-profile.json | 32 + .../fixed-server-regression.json | 68 + .../2026-09-24-signal-safe-idle/package.json | 14 + .../pure-document-fixed-http-linux-aarch64.tar.gz | Bin 0 -> 32023 bytes .../reproducible-comparison.json | 21 + .../service-scale-restored.json | 62 + .../2026-09-24-signal-safe-idle/service-scale.json | 60 + .../source-profile.json | 32 + .../pure-source-preview.tar.gz | Bin 0 -> 316748 bytes .../2026-09-24-source-preview/source-check.json | 12 + .../results/2026-09-24-source-tui/core.json | 53 + .../2026-09-24-source-tui/fixed-cli-tui-check.json | 14 + .../2026-09-24-source-tui/fixed-cli-tui.tar.gz | Bin 0 -> 31929 bytes .../2026-09-24-source-tui/fixed-tui-profile.json | 21 + .../pure-source-preview.tar.gz | Bin 0 -> 318619 bytes .../2026-09-24-source-tui/source-check.json | 13 + .../source-cli-tui-check.json | 14 + .../2026-09-24-source-tui/source-cli-tui.tar.gz | Bin 0 -> 50942 bytes .../results/2026-09-24-source-tui/tui-profile.json | 21 + .../results/2026-09-24-source-tui/tui.json | 25 + .../cli-check.json | 12 + .../cli-http-tui-check.json | 16 + .../cli-http-tui.tar.gz | Bin 0 -> 53385 bytes .../cli-tui-check.json | 14 + .../cli-tui.tar.gz | Bin 0 -> 35642 bytes .../2026-09-24-terminal-document-list/cli.tar.gz | Bin 0 -> 14706 bytes .../2026-09-24-terminal-document-list/profile.json | 21 + .../sanitized.json | 22 + .../2026-09-24-terminal-document-list/tui.json | 22 + .../2026-09-24-tui-viewport/latency-baseline.json | 176 +++ .../2026-09-24-tui-viewport/latency-current.json | 176 +++ .../2026-09-24-tui-viewport/package-http-tui.json | 16 + .../results/2026-09-24-tui-viewport/package.json | 14 + .../results/2026-09-24-tui-viewport/profile.json | 21 + .../results/2026-09-24-tui-viewport/pty-flow.json | 19 + .../2026-09-24-tui-viewport/pty-sanitized.json | 19 + ...pure-document-cli-http-tui-linux-aarch64.tar.gz | Bin 0 -> 51844 bytes .../pure-document-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 34216 bytes .../final-native-window.json | 1 + .../line-width-4096.json | 1 + .../mallinfo.json | 1 + .../no-document-undo.json | 1 + .../no-key-undo.json | 1 + .../2026-09-24-window-memory-analysis/no-wrap.json | 1 + .../replace-view.json | 1 + .../same-body.json | 1 + .../same-key.json | 1 + .../settled-final.json | 1 + .../skip-text.json | 1 + .../2026-09-24-window-memory-analysis/smaps.json | 1 + .../pure-document-fixed-cli-linux-aarch64.tar.gz | Bin 0 -> 12972 bytes ...ure-document-fixed-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 32119 bytes .../pure-document-source-cli-linux-aarch64.tar.gz | Bin 0 -> 22784 bytes ...re-document-source-cli-tui-linux-aarch64.tar.gz | Bin 0 -> 51167 bytes .../pure-source-preview.tar.gz | Bin 0 -> 334267 bytes .../2026-09-24-work-slot/source-package-check.json | 16 + prototypes/connections/runtime.c | 393 +++++ prototypes/connections/runtime.h | 147 ++ prototypes/connections/server.c | 645 ++++++++ prototypes/connections/source-tui-budget.json | 12 + prototypes/connections/store-paged.c | 529 +++++++ prototypes/connections/store-port.c | 51 + prototypes/connections/store-port.h | 7 + prototypes/connections/store.c | 309 ++++ prototypes/connections/store.h | 48 + prototypes/connections/test-async-server-gate.c | 21 + prototypes/connections/test-async.c | 330 +++++ prototypes/connections/test-fields.c | 153 ++ prototypes/connections/test-flow.c | 183 +++ prototypes/connections/test-paged-store.c | 270 ++++ prototypes/connections/test-program.c | 111 ++ prototypes/connections/test-runtime.c | 356 +++++ prototypes/connections/test-splice.c | 190 +++ prototypes/connections/tui-budget.json | 12 + prototypes/connections/window-budget.json | 12 + prototypes/forgetful-core/README.md | 116 ++ prototypes/forgetful-core/check.py | 69 + prototypes/forgetful-core/core.c | 163 +++ prototypes/forgetful-core/core.h | 58 + prototypes/forgetful-core/test.c | 256 ++++ prototypes/forgetful-core/verification.json | 67 + prototypes/live-rules/.gitignore | 1 + prototypes/live-rules/README.md | 126 ++ prototypes/live-rules/adapter.test.mjs | 96 ++ prototypes/live-rules/cli.mjs | 64 + prototypes/live-rules/core.zig | 504 +++++++ prototypes/live-rules/manage.py | 88 ++ prototypes/live-rules/measure.mjs | 48 + prototypes/live-rules/qa-browser.js | 131 ++ .../2026-09-22-linux-aarch64/browser-checks.json | 215 +++ .../results/2026-09-22-linux-aarch64/desktop.png | Bin 0 -> 150506 bytes .../results/2026-09-22-linux-aarch64/mobile.png | Bin 0 -> 123520 bytes .../storage-and-replay.json | 503 +++++++ prototypes/live-rules/runtime.mjs | 100 ++ prototypes/live-rules/web/app.js | 294 ++++ prototypes/live-rules/web/core.wasm | Bin 0 -> 32544 bytes prototypes/live-rules/web/index.html | 101 ++ prototypes/live-rules/web/style.css | 1 + prototypes/workbench/.gitignore | 2 + prototypes/workbench/CONTRACT.md | 575 ++++++++ prototypes/workbench/README.md | 354 +++++ prototypes/workbench/baseline.test.mjs | 78 + prototypes/workbench/cli.mjs | 127 ++ prototypes/workbench/cli.test.mjs | 121 ++ prototypes/workbench/compare.mjs | 231 +++ prototypes/workbench/compiler.test.mjs | 240 +++ prototypes/workbench/core.zig | 999 +++++++++++++ prototypes/workbench/engine.zig | 700 +++++++++ prototypes/workbench/evaluation-check.mjs | 76 + prototypes/workbench/evaluation.test.mjs | 133 ++ prototypes/workbench/evaluation.zig | 164 +++ prototypes/workbench/examples/charge.pure | 34 + prototypes/workbench/examples/counter.pure | 8 + prototypes/workbench/examples/direct.pure | 47 + prototypes/workbench/examples/document.pure | 6 + prototypes/workbench/examples/layout.pure | 92 ++ prototypes/workbench/examples/routed-layout.pure | 70 + prototypes/workbench/examples/shared-layout.pure | 75 + prototypes/workbench/examples/toggle.pure | 17 + prototypes/workbench/manage.py | 161 ++ prototypes/workbench/native-fixed.zig | 11 + prototypes/workbench/native.zig | 146 ++ prototypes/workbench/native/README.md | 136 ++ prototypes/workbench/native/app-memory.c | 87 ++ prototypes/workbench/native/app.c | 260 ++++ prototypes/workbench/native/app.h | 32 + prototypes/workbench/native/check.mjs | 178 +++ prototypes/workbench/native/check_app_memory.py | 103 ++ prototypes/workbench/native/desktop.c | 503 +++++++ prototypes/workbench/native/instance-memory.c | 49 + prototypes/workbench/native/main.c | 135 ++ prototypes/workbench/native/peer-check.py | 228 +++ prototypes/workbench/native/peer-wire-test.c | 85 ++ prototypes/workbench/native/peer-wire.c | 152 ++ prototypes/workbench/native/peer-wire.h | 32 + prototypes/workbench/native/peer.c | 297 ++++ prototypes/workbench/native/plan.c | 45 + prototypes/workbench/native/plan.h | 39 + prototypes/workbench/native/prepare.mjs | 67 + prototypes/workbench/native/scalar.h | 51 + prototypes/workbench/native/test-fixed.c | 68 + prototypes/workbench/native/test.c | 152 ++ prototypes/workbench/pointer.test.mjs | 118 ++ prototypes/workbench/presentation.test.mjs | 132 ++ prototypes/workbench/profile-refresh.js | 66 + prototypes/workbench/project.test.mjs | 79 + prototypes/workbench/qa-browser.js | 228 +++ prototypes/workbench/qa-document-guards.js | 30 + prototypes/workbench/qa-document.js | 103 ++ prototypes/workbench/qa-evaluation.js | 89 ++ prototypes/workbench/qa-layout.js | 103 ++ prototypes/workbench/qa-pointer.js | 122 ++ prototypes/workbench/qa-presentation.js | 141 ++ prototypes/workbench/qa-presentation.py | 54 + prototypes/workbench/qa-refresh.js | 141 ++ prototypes/workbench/qa-refresh.py | 53 + prototypes/workbench/qa-release.js | 59 + prototypes/workbench/qa-routed-input.js | 164 +++ prototypes/workbench/qa-shared-rules.js | 137 ++ prototypes/workbench/remote.mjs | 89 ++ .../results/2026-09-22-evaluation/browser.json | 231 +++ .../results/2026-09-22-evaluation/comparison.json | 309 ++++ .../sources/baseline.test.mjs | 78 + .../results/2026-09-22-evaluation/sources/cli.mjs | 69 + .../2026-09-22-evaluation/sources/cli.test.mjs | 83 ++ .../sources/compiler.test.mjs | 240 +++ .../results/2026-09-22-evaluation/sources/core.zig | 813 +++++++++++ .../sources/evaluation-check.mjs | 76 + .../sources/evaluation.test.mjs | 133 ++ .../2026-09-22-evaluation/sources/evaluation.zig | 156 ++ .../sources/examples/charge.pure | 34 + .../sources/examples/counter.pure | 8 + .../sources/examples/toggle.pure | 17 + .../2026-09-22-evaluation/sources/manage.py | 87 ++ .../2026-09-22-evaluation/sources/manifest.json | 21 + .../2026-09-22-evaluation/sources/qa-evaluation.js | 86 ++ .../2026-09-22-evaluation/sources/runtime.test.mjs | 197 +++ .../2026-09-22-evaluation/sources/web/app.js | 427 ++++++ .../2026-09-22-evaluation/sources/web/baseline.mjs | 68 + .../2026-09-22-evaluation/sources/web/compiler.mjs | 303 ++++ .../2026-09-22-evaluation/sources/web/core.wasm | Bin 0 -> 53125 bytes .../2026-09-22-evaluation/sources/web/runtime.mjs | 197 +++ .../2026-09-22-evaluation/verification.json | 68 + .../results/2026-09-22-linux-aarch64/authored.png | Bin 0 -> 176600 bytes .../results/2026-09-22-linux-aarch64/blank.png | Bin 0 -> 148863 bytes .../2026-09-22-linux-aarch64/browser-checks.json | 159 ++ .../2026-09-22-linux-aarch64/comparison.json | 298 ++++ .../2026-09-22-linux-aarch64/exported-draft.json | 5 + .../results/2026-09-22-linux-aarch64/mobile.png | Bin 0 -> 151662 bytes .../2026-09-22-linux-aarch64/verification.json | 239 +++ .../workbench/results/2026-09-23-layout/README.md | 21 + .../2026-09-23-layout/authoring-browser.json | 128 ++ .../workbench/results/2026-09-23-layout/build.json | 23 + .../results/2026-09-23-layout/layout-browser.json | 97 ++ .../results/2026-09-23-layout/manifest.json | 21 + .../2026-09-23-layout/pure-layout-desktop.png | Bin 0 -> 206894 bytes .../2026-09-23-layout/pure-layout-mobile.png | Bin 0 -> 162877 bytes .../2026-09-23-layout/pure-layout-saved.json | 96 ++ .../results/2026-09-23-layout/qa-browser.js | 228 +++ .../results/2026-09-23-layout/sources/CONTRACT.md | 371 +++++ .../results/2026-09-23-layout/sources/cli.mjs | 93 ++ .../results/2026-09-23-layout/sources/cli.test.mjs | 121 ++ .../results/2026-09-23-layout/sources/core.zig | 1136 +++++++++++++++ .../2026-09-23-layout/sources/evaluation.zig | 164 +++ .../2026-09-23-layout/sources/examples/layout.pure | 92 ++ .../2026-09-23-layout/sources/project.test.mjs | 79 + .../results/2026-09-23-layout/sources/qa-layout.js | 103 ++ .../results/2026-09-23-layout/sources/web/app.js | 403 +++++ .../2026-09-23-layout/sources/web/core.wasm | Bin 0 -> 56203 bytes .../2026-09-23-layout/sources/web/index.html | 94 ++ .../2026-09-23-layout/sources/web/project.mjs | 45 + .../2026-09-23-layout/sources/web/runtime.mjs | 324 +++++ .../2026-09-23-layout/sources/web/scene-input.mjs | 83 ++ .../2026-09-23-layout/sources/workspace.test.mjs | 89 ++ .../results/2026-09-23-layout/verification.json | 56 + .../results/2026-09-23-native-instances/README.md | 36 + .../application-build.json | 40 + .../auxiliary-sources.json | 28 + .../2026-09-23-native-instances/browser-build.json | 24 + .../2026-09-23-native-instances/browser-tests.log | 95 ++ .../default-linkage.json | 12 + .../2026-09-23-native-instances/native.json | 159 ++ .../2026-09-23-native-instances/sanitized.json | 159 ++ .../sources/prototypes/connections/evaluate.c | 53 + .../sources/prototypes/connections/flow.h | 25 + .../sources/prototypes/connections/program.h | 44 + .../sources/prototypes/connections/result.h | 8 + .../sources/prototypes/connections/runtime.c | 273 ++++ .../sources/prototypes/connections/runtime.h | 88 ++ .../sources/prototypes/connections/store-port.c | 44 + .../sources/prototypes/connections/store-port.h | 6 + .../sources/prototypes/connections/store.c | 258 ++++ .../sources/prototypes/connections/store.h | 44 + .../sources/prototypes/workbench/baseline.test.mjs | 78 + .../sources/prototypes/workbench/cli.mjs | 127 ++ .../sources/prototypes/workbench/cli.test.mjs | 121 ++ .../sources/prototypes/workbench/compare.mjs | 231 +++ .../sources/prototypes/workbench/compiler.test.mjs | 240 +++ .../sources/prototypes/workbench/core.zig | 999 +++++++++++++ .../sources/prototypes/workbench/engine.zig | 601 ++++++++ .../prototypes/workbench/evaluation-check.mjs | 76 + .../prototypes/workbench/evaluation.test.mjs | 133 ++ .../sources/prototypes/workbench/evaluation.zig | 164 +++ .../prototypes/workbench/examples/charge.pure | 34 + .../prototypes/workbench/examples/counter.pure | 8 + .../prototypes/workbench/examples/direct.pure | 47 + .../prototypes/workbench/examples/document.pure | 6 + .../prototypes/workbench/examples/layout.pure | 92 ++ .../workbench/examples/routed-layout.pure | 70 + .../workbench/examples/shared-layout.pure | 75 + .../prototypes/workbench/examples/toggle.pure | 17 + .../sources/prototypes/workbench/manage.py | 127 ++ .../sources/prototypes/workbench/native.zig | 127 ++ .../sources/prototypes/workbench/native/app.c | 208 +++ .../sources/prototypes/workbench/native/app.h | 27 + .../sources/prototypes/workbench/native/check.mjs | 129 ++ .../sources/prototypes/workbench/native/main.c | 135 ++ .../sources/prototypes/workbench/native/plan.c | 38 + .../sources/prototypes/workbench/native/plan.h | 36 + .../prototypes/workbench/native/prepare.mjs | 35 + .../sources/prototypes/workbench/native/scalar.h | 46 + .../sources/prototypes/workbench/native/test.c | 120 ++ .../sources/prototypes/workbench/pointer.test.mjs | 118 ++ .../prototypes/workbench/presentation.test.mjs | 132 ++ .../sources/prototypes/workbench/project.test.mjs | 79 + .../sources/prototypes/workbench/remote.mjs | 89 ++ .../sources/prototypes/workbench/routing.test.mjs | 114 ++ .../sources/prototypes/workbench/rules.test.mjs | 94 ++ .../sources/prototypes/workbench/runtime.test.mjs | 197 +++ .../prototypes/workbench/value-input.test.mjs | 50 + .../sources/prototypes/workbench/web/baseline.mjs | 68 + .../sources/prototypes/workbench/web/compiler.mjs | 496 +++++++ .../sources/prototypes/workbench/web/core.wasm | Bin 0 -> 61065 bytes .../sources/prototypes/workbench/web/project.mjs | 47 + .../sources/prototypes/workbench/web/runtime.mjs | 350 +++++ .../prototypes/workbench/workspace-check.mjs | 62 + .../prototypes/workbench/workspace.test.mjs | 91 ++ .../2026-09-23-native-instances/verification.json | 39 + .../results/2026-09-23-pointer-routing/README.md | 51 + .../authoring-result.log | 13 + .../2026-09-23-pointer-routing/authoring-run.log | 237 +++ .../2026-09-23-pointer-routing/authoring.json | 130 ++ .../2026-09-23-pointer-routing/before/compiler.mjs | 447 ++++++ .../2026-09-23-pointer-routing/before/core.wasm | Bin 0 -> 58440 bytes .../2026-09-23-pointer-routing/before/core.zig | 1230 ++++++++++++++++ .../2026-09-23-pointer-routing/before/runtime.mjs | 328 +++++ .../browser-initial-failure.json | 32 + .../2026-09-23-pointer-routing/browser-result.log | 12 + .../2026-09-23-pointer-routing/browser-run.log | 172 +++ .../2026-09-23-pointer-routing/browser.json | 113 ++ .../results/2026-09-23-pointer-routing/build.json | 23 + .../2026-09-23-pointer-routing/legacy-browser.json | 106 ++ .../2026-09-23-pointer-routing/legacy-result.log | 12 + .../2026-09-23-pointer-routing/legacy-run.log | 146 ++ .../2026-09-23-pointer-routing/manifest.json | 47 + .../2026-09-23-pointer-routing/node-tests.log | 91 ++ .../pure-routed-layout-desktop.png | Bin 0 -> 211960 bytes .../pure-routed-layout-mobile.png | Bin 0 -> 165704 bytes .../pure-routed-layout-saved.json | 108 ++ .../2026-09-23-pointer-routing/qa-authoring.js | 228 +++ .../qa-legacy-pointer.js | 138 ++ .../2026-09-23-pointer-routing/representation.json | 73 + .../2026-09-23-pointer-routing/sources/CONTRACT.md | 496 +++++++ .../2026-09-23-pointer-routing/sources/cli.mjs | 98 ++ .../2026-09-23-pointer-routing/sources/core.zig | 1398 ++++++++++++++++++ .../sources/evaluation.zig | 164 +++ .../sources/examples/routed-layout.pure | 70 + .../sources/presentation.test.mjs | 132 ++ .../sources/qa-routed-input.js | 164 +++ .../sources/routing.test.mjs | 114 ++ .../2026-09-23-pointer-routing/sources/web/app.js | 403 +++++ .../sources/web/compiler.mjs | 484 ++++++ .../sources/web/core.wasm | Bin 0 -> 60774 bytes .../sources/web/index.html | 94 ++ .../sources/web/output.mjs | 103 ++ .../sources/web/runtime.mjs | 339 +++++ .../sources/web/scene-input.mjs | 84 ++ .../sources/workspace.test.mjs | 91 ++ .../2026-09-23-pointer-routing/touch-evidence.json | 246 ++++ .../2026-09-23-pointer-routing/touch-evidence.log | 12 + .../touch-reproduction.js | 21 + .../touch-reproduction.log | 28 + .../2026-09-23-pointer-routing/touch-run.log | 29 + .../2026-09-23-pointer-routing/verification.json | 13 + .../workbench/results/2026-09-23-pointer/README.md | 23 + .../results/2026-09-23-pointer/after/CONTRACT.md | 308 ++++ .../results/2026-09-23-pointer/after/cli.mjs | 74 + .../results/2026-09-23-pointer/after/cli.test.mjs | 97 ++ .../results/2026-09-23-pointer/after/core.zig | 1027 +++++++++++++ .../2026-09-23-pointer/after/evaluation.zig | 164 +++ .../2026-09-23-pointer/after/examples/direct.pure | 48 + .../2026-09-23-pointer/after/pointer.test.mjs | 110 ++ .../results/2026-09-23-pointer/after/qa-pointer.js | 120 ++ .../results/2026-09-23-pointer/after/web/app.js | 378 +++++ .../2026-09-23-pointer/after/web/compiler.mjs | 326 +++++ .../results/2026-09-23-pointer/after/web/core.wasm | Bin 0 -> 54511 bytes .../2026-09-23-pointer/after/web/index.html | 93 ++ .../2026-09-23-pointer/after/web/output.mjs | 95 ++ .../2026-09-23-pointer/after/web/runtime.mjs | 298 ++++ .../2026-09-23-pointer/after/web/scene-input.mjs | 68 + .../results/2026-09-23-pointer/after/web/style.css | 10 + .../2026-09-23-pointer/authoring-browser.json | 128 ++ .../results/2026-09-23-pointer/before/cli.mjs | 69 + .../results/2026-09-23-pointer/before/core.zig | 942 ++++++++++++ .../2026-09-23-pointer/before/evaluation.zig | 164 +++ .../2026-09-23-pointer/before/manifest.json | 12 + .../results/2026-09-23-pointer/before/web/app.js | 368 +++++ .../2026-09-23-pointer/before/web/compiler.mjs | 303 ++++ .../2026-09-23-pointer/before/web/core.wasm | Bin 0 -> 53952 bytes .../2026-09-23-pointer/before/web/index.html | 93 ++ .../2026-09-23-pointer/before/web/output.mjs | 92 ++ .../2026-09-23-pointer/before/web/runtime.mjs | 267 ++++ .../2026-09-23-pointer/before/web/style.css | 8 + .../results/2026-09-23-pointer/build.json | 23 + .../results/2026-09-23-pointer/manifest.json | 23 + .../2026-09-23-pointer/pointer-browser.json | 32 + .../2026-09-23-pointer/pure-pointer-desktop.png | Bin 0 -> 197508 bytes .../2026-09-23-pointer/pure-pointer-mobile.png | Bin 0 -> 159460 bytes .../results/2026-09-23-pointer/qa-browser.js | 228 +++ .../results/2026-09-23-pointer/qa-pointer.js | 120 ++ .../results/2026-09-23-pointer/verification.json | 50 + .../2026-09-23-presentation/before/core.zig | 942 ++++++++++++ .../2026-09-23-presentation/before/evaluation.zig | 164 +++ .../2026-09-23-presentation/before/manifest.json | 10 + .../2026-09-23-presentation/before/web/app.js | 427 ++++++ .../before/web/compiler.mjs | 303 ++++ .../2026-09-23-presentation/before/web/core.wasm | Bin 0 -> 53952 bytes .../2026-09-23-presentation/before/web/index.html | 93 ++ .../2026-09-23-presentation/before/web/runtime.mjs | 205 +++ .../2026-09-23-presentation/before/web/style.css | 8 + .../2026-09-23-presentation/browser-authoring.json | 131 ++ .../2026-09-23-presentation/browser-limit.json | 242 +++ .../results/2026-09-23-presentation/browser.json | 1536 ++++++++++++++++++++ .../2026-09-23-presentation/verification.json | 58 + .../results/2026-09-23-refresh/before/core.zig | 942 ++++++++++++ .../2026-09-23-refresh/before/evaluation.zig | 164 +++ .../2026-09-23-refresh/before/manifest.json | 14 + .../before/presentation.test.mjs | 123 ++ .../2026-09-23-refresh/before/qa-presentation.js | 141 ++ .../2026-09-23-refresh/before/qa-presentation.py | 54 + .../results/2026-09-23-refresh/before/web/app.js | 349 +++++ .../2026-09-23-refresh/before/web/compiler.mjs | 303 ++++ .../2026-09-23-refresh/before/web/core.wasm | Bin 0 -> 53952 bytes .../2026-09-23-refresh/before/web/index.html | 93 ++ .../2026-09-23-refresh/before/web/output.mjs | 92 ++ .../2026-09-23-refresh/before/web/runtime.mjs | 267 ++++ .../2026-09-23-refresh/before/web/style.css | 8 + .../2026-09-23-refresh/browser-authoring.json | 131 ++ .../results/2026-09-23-refresh/browser-limit.json | 242 +++ .../results/2026-09-23-refresh/browser.json | 1487 +++++++++++++++++++ .../results/2026-09-23-refresh/profile-before.json | 34 + .../results/2026-09-23-refresh/verification.json | 41 + .../workbench/results/2026-09-23-release/README.md | 19 + .../results/2026-09-23-release/after-browser.json | 62 + .../results/2026-09-23-release/after/cli.test.mjs | 97 ++ .../2026-09-23-release/after/examples/direct.pure | 47 + .../2026-09-23-release/after/pointer.test.mjs | 118 ++ .../results/2026-09-23-release/after/qa-pointer.js | 122 ++ .../results/2026-09-23-release/after/qa-release.js | 59 + .../after/web/examples/direct.pure | 47 + .../2026-09-23-release/after/web/scene-input.mjs | 83 ++ .../results/2026-09-23-release/before-browser.json | 21 + .../results/2026-09-23-release/manifest.json | 6 + .../2026-09-23-release/pointer-browser.json | 33 + .../results/2026-09-23-shared-rules/README.md | 24 + .../2026-09-23-shared-rules/authoring-browser.json | 128 ++ .../2026-09-23-shared-rules/before/core.zig | 1136 +++++++++++++++ .../before/examples/layout.pure | 92 ++ .../before/web/compiler.mjs | 326 +++++ .../2026-09-23-shared-rules/before/web/core.wasm | Bin 0 -> 56203 bytes .../2026-09-23-shared-rules/before/web/runtime.mjs | 324 +++++ .../results/2026-09-23-shared-rules/build.json | 23 + .../results/2026-09-23-shared-rules/manifest.json | 21 + .../pure-shared-layout-desktop.png | Bin 0 -> 228262 bytes .../pure-shared-layout-mobile.png | Bin 0 -> 167873 bytes .../pure-shared-layout-saved.json | 108 ++ .../results/2026-09-23-shared-rules/qa-browser.js | 228 +++ .../2026-09-23-shared-rules/representation.json | 403 +++++ .../2026-09-23-shared-rules/shared-browser.json | 106 ++ .../2026-09-23-shared-rules/sources/CONTRACT.md | 437 ++++++ .../2026-09-23-shared-rules/sources/core.zig | 1230 ++++++++++++++++ .../2026-09-23-shared-rules/sources/evaluation.zig | 164 +++ .../sources/examples/shared-layout.pure | 75 + .../sources/qa-shared-rules.js | 137 ++ .../2026-09-23-shared-rules/sources/rules.test.mjs | 94 ++ .../sources/runtime.test.mjs | 197 +++ .../2026-09-23-shared-rules/sources/web/app.js | 403 +++++ .../sources/web/compiler.mjs | 447 ++++++ .../2026-09-23-shared-rules/sources/web/core.wasm | Bin 0 -> 58440 bytes .../2026-09-23-shared-rules/sources/web/index.html | 94 ++ .../sources/web/project.mjs | 47 + .../sources/web/runtime.mjs | 328 +++++ .../sources/web/scene-input.mjs | 83 ++ .../sources/workspace.test.mjs | 89 ++ .../2026-09-23-shared-rules/verification.json | 62 + .../results/2026-09-23-web-layout/README.md | 47 + .../2026-09-23-web-layout/authoring-result.log | 13 + .../2026-09-23-web-layout/authoring-run.log | 237 +++ .../results/2026-09-23-web-layout/authoring.json | 130 ++ .../2026-09-23-web-layout/before/compiler.mjs | 484 ++++++ .../results/2026-09-23-web-layout/before/core.wasm | Bin 0 -> 60774 bytes .../results/2026-09-23-web-layout/before/core.zig | 1398 ++++++++++++++++++ .../2026-09-23-web-layout/before/runtime.mjs | 339 +++++ .../2026-09-23-web-layout/browser-result.log | 12 + .../results/2026-09-23-web-layout/browser-run.log | 111 ++ .../results/2026-09-23-web-layout/browser.json | 89 ++ .../results/2026-09-23-web-layout/build.json | 23 + .../2026-09-23-web-layout/compiler-check.log | 21 + .../2026-09-23-web-layout/guards-result.log | 9 + .../results/2026-09-23-web-layout/guards-run.log | 38 + .../results/2026-09-23-web-layout/guards.json | 10 + .../results/2026-09-23-web-layout/manifest.json | 45 + .../results/2026-09-23-web-layout/native-build.log | 39 + .../results/2026-09-23-web-layout/node-tests.log | 95 ++ .../pure-document-desktop.png | Bin 0 -> 184975 bytes .../2026-09-23-web-layout/pure-document-mobile.png | Bin 0 -> 106808 bytes .../2026-09-23-web-layout/pure-document-saved.txt | 3 + .../results/2026-09-23-web-layout/qa-authoring.js | 229 +++ .../2026-09-23-web-layout/qa-value-control.js | 12 + .../2026-09-23-web-layout/sources/CONTRACT.md | 568 ++++++++ .../results/2026-09-23-web-layout/sources/cli.mjs | 103 ++ .../results/2026-09-23-web-layout/sources/core.zig | 1443 ++++++++++++++++++ .../2026-09-23-web-layout/sources/evaluation.zig | 164 +++ .../sources/examples/document.pure | 6 + .../sources/qa-document-guards.js | 30 + .../2026-09-23-web-layout/sources/qa-document.js | 103 ++ .../sources/value-input.test.mjs | 50 + .../2026-09-23-web-layout/sources/web/app.js | 411 ++++++ .../2026-09-23-web-layout/sources/web/compiler.mjs | 496 +++++++ .../2026-09-23-web-layout/sources/web/core.wasm | Bin 0 -> 61072 bytes .../2026-09-23-web-layout/sources/web/document.css | 3 + .../sources/web/document.html | 32 + .../2026-09-23-web-layout/sources/web/document.mjs | 132 ++ .../2026-09-23-web-layout/sources/web/index.html | 95 ++ .../2026-09-23-web-layout/sources/web/runtime.mjs | 350 +++++ .../sources/web/split-input.mjs | 57 + .../2026-09-23-web-layout/value-control.json | 7 + .../2026-09-23-web-layout/value-control.log | 18 + .../2026-09-23-web-layout/verification.json | 14 + .../results/2026-09-23-workspace/browser.json | 240 +++ .../results/2026-09-23-workspace/comparison.json | 142 ++ .../results/2026-09-23-workspace/verification.json | 75 + .../app-memory-three-way-final.json | 160 ++ .../2026-09-24-fixed-capacity/fixed-cli-build.json | 48 + .../fixed-parity-final.json | 209 +++ .../fixed-peer-build.json | 52 + .../general-parity-final.json | 209 +++ .../2026-09-24-fixed-capacity/instance-memory.json | 65 + .../2026-09-24-fixed-capacity/peer-check.json | 1 + .../results/2026-09-24-fixed-capacity/size.txt | 6 + .../results/2026-09-24-fixed-capacity/stripped.txt | 3 + .../2026-09-24-idle-work-binding/app-memory.json | 108 ++ .../native-sanitized.json | 1 + .../2026-09-24-idle-work-binding/native-unit.json | 1 + .../2026-09-24-idle-work-binding/scalar-peer.json | 1 + .../2026-09-24-idle-work-binding/stack-baseline.su | 9 + .../2026-09-24-idle-work-binding/stack-elastic.su | 9 + .../results/2026-09-24-native-desktop/README.md | 38 + .../2026-09-24-native-desktop/authored-after.pure | 72 + .../2026-09-24-native-desktop/capture-x11.c | 43 + .../2026-09-24-native-desktop/core-budget.json | 53 + .../2026-09-24-native-desktop/drag-and-save.png | Bin 0 -> 134122 bytes .../2026-09-24-native-desktop/manifest.json | 141 ++ .../native-parity-sanitized.json | 159 ++ .../2026-09-24-native-desktop/native-parity.json | 159 ++ .../2026-09-24-native-desktop/numeric-input.png | Bin 0 -> 45958 bytes .../2026-09-24-native-desktop/recovered-edit.png | Bin 0 -> 139858 bytes .../2026-09-24-native-desktop/rejected-edit.png | Bin 0 -> 142502 bytes .../2026-09-24-native-desktop/reopened-state.png | Bin 0 -> 134717 bytes .../2026-09-24-native-desktop/resized-input.png | Bin 0 -> 108314 bytes .../results/2026-09-24-native-desktop/resized.png | Bin 0 -> 108735 bytes .../2026-09-24-native-desktop/sources/manage.py | 137 ++ .../2026-09-24-native-desktop/sources/native/app.c | 239 +++ .../2026-09-24-native-desktop/sources/native/app.h | 32 + .../sources/native/check.mjs | 132 ++ .../sources/native/desktop.c | 503 +++++++ .../sources/native/test.c | 151 ++ .../results/2026-09-24-secure-peer/build.json | 48 + .../results/2026-09-24-secure-peer/peer-check.json | 1 + .../results/2026-09-24-secure-peer/size.txt | 4 + .../2026-09-24-shared-program/cli-build.json | 49 + .../2026-09-24-shared-program/dependencies.txt | 6 + .../2026-09-24-shared-program/desktop-build.json | 84 ++ .../2026-09-24-shared-program/fixed-parity.json | 205 +++ .../2026-09-24-shared-program/general-parity.json | 209 +++ .../2026-09-24-shared-program/instance-memory.json | 71 + .../2026-09-24-shared-program/peer-build.json | 53 + .../2026-09-24-shared-program/peer-check.json | 1 + .../results/2026-09-24-shared-program/size.txt | 5 + .../results/2026-09-24-shared-program/stripped.txt | 2 + .../results/2026-09-24-shared-program/symbols.txt | 4 + prototypes/workbench/routing.test.mjs | 114 ++ prototypes/workbench/rules.test.mjs | 94 ++ prototypes/workbench/runtime.test.mjs | 197 +++ prototypes/workbench/value-input.test.mjs | 50 + prototypes/workbench/web/app.js | 411 ++++++ prototypes/workbench/web/baseline.html | 108 ++ prototypes/workbench/web/baseline.mjs | 68 + prototypes/workbench/web/compiler.mjs | 496 +++++++ prototypes/workbench/web/core.wasm | Bin 0 -> 61073 bytes prototypes/workbench/web/document.css | 3 + prototypes/workbench/web/document.html | 32 + prototypes/workbench/web/document.mjs | 132 ++ prototypes/workbench/web/index.html | 95 ++ prototypes/workbench/web/output.mjs | 103 ++ prototypes/workbench/web/project.mjs | 47 + prototypes/workbench/web/runtime.mjs | 350 +++++ prototypes/workbench/web/scene-input.mjs | 84 ++ prototypes/workbench/web/split-input.mjs | 57 + prototypes/workbench/web/style.css | 10 + prototypes/workbench/workspace-check.mjs | 62 + prototypes/workbench/workspace.test.mjs | 91 ++ 1095 files changed, 125691 insertions(+) create mode 100644 prototypes/common/tls-link.c create mode 100644 prototypes/common/tls-link.h create mode 100644 prototypes/connections/.gitignore create mode 100644 prototypes/connections/Makefile create mode 100644 prototypes/connections/README.md create mode 100644 prototypes/connections/SOURCE_PREVIEW.md create mode 100644 prototypes/connections/async-core-budget.json create mode 100644 prototypes/connections/async-server-budget.json create mode 100644 prototypes/connections/check.py create mode 100644 prototypes/connections/check_async_server.py create mode 100644 prototypes/connections/check_copy_path.py create mode 100644 prototypes/connections/check_core.py create mode 100644 prototypes/connections/check_document_peer.py create mode 100644 prototypes/connections/check_document_server_package.py create mode 100644 prototypes/connections/check_edits.py create mode 100644 prototypes/connections/check_fixed_program.py create mode 100644 prototypes/connections/check_fixed_server.py create mode 100644 prototypes/connections/check_flat_export.py create mode 100644 prototypes/connections/check_idle_wakes.py create mode 100644 prototypes/connections/check_micro_memory.py create mode 100644 prototypes/connections/check_mirror.py create mode 100644 prototypes/connections/check_package.py create mode 100644 prototypes/connections/check_policy.py create mode 100644 prototypes/connections/check_profile.py create mode 100644 prototypes/connections/check_service_scale.py create mode 100644 prototypes/connections/check_small_scan.py create mode 100644 prototypes/connections/check_source_package.py create mode 100644 prototypes/connections/check_tui.py create mode 100644 prototypes/connections/check_window.py create mode 100644 prototypes/connections/check_write_cost.py create mode 100644 prototypes/connections/compile-program.c create mode 100644 prototypes/connections/core-budget.json create mode 100644 prototypes/connections/core-layout.c create mode 100644 prototypes/connections/document-peer.c create mode 100644 prototypes/connections/edit-tui.c create mode 100644 prototypes/connections/edit-window.c create mode 100644 prototypes/connections/edit.c create mode 100644 prototypes/connections/evaluate.c create mode 100644 prototypes/connections/examples/bounded-editor.pure create mode 100644 prototypes/connections/examples/editor.pure create mode 100644 prototypes/connections/examples/excerpt.pure create mode 100644 prototypes/connections/examples/select-text.py create mode 100644 prototypes/connections/examples/selection.pure create mode 100644 prototypes/connections/examples/storage.pure create mode 100644 prototypes/connections/export_flat_store.py create mode 100644 prototypes/connections/fixed-budget.json create mode 100644 prototypes/connections/fixed-package-budget.json create mode 100644 prototypes/connections/flow.c create mode 100644 prototypes/connections/flow.h create mode 100644 prototypes/connections/hybrid-budget.json create mode 100644 prototypes/connections/hybrid-tui-budget.json create mode 100644 prototypes/connections/measure-peak-rss.c create mode 100644 prototypes/connections/measure_tui_latency.py create mode 100644 prototypes/connections/memory-budget.json create mode 100644 prototypes/connections/mirror.c create mode 100644 prototypes/connections/package_native.py create mode 100644 prototypes/connections/package_source.py create mode 100644 prototypes/connections/paged-budget.json create mode 100644 prototypes/connections/peer-budget.json create mode 100644 prototypes/connections/peer-package-budget.json create mode 100644 prototypes/connections/peer-server-budget.json create mode 100644 prototypes/connections/profile-budget.json create mode 100644 prototypes/connections/program.c create mode 100644 prototypes/connections/program.h create mode 100644 prototypes/connections/result.h create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/README.md create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/initial/native.json create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/initial/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/initial/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/native.json create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/Makefile create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/check.py create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.c create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.h create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/server.c create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.c create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.h create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/compiler.mjs create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/connections/results/2026-09-23-byte-storage/verification.json create mode 100644 prototypes/connections/results/2026-09-23-document-edits/README.md create mode 100644 prototypes/connections/results/2026-09-23-document-edits/boundary-checks.json create mode 100644 prototypes/connections/results/2026-09-23-document-edits/core.json create mode 100644 prototypes/connections/results/2026-09-23-document-edits/native.json create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/Makefile create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check.py create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check_core.py create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check_edits.py create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/core-budget.json create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/core-layout.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/edit.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/evaluate.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/editor.pure create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/excerpt.pure create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/select-text.py create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/selection.pure create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/storage.pure create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.h create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.h create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/result.h create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.h create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/server.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.h create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.h create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-fields.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-program.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-runtime.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-splice.c create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/compiler.mjs create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/connections/results/2026-09-23-document-edits/verification.json create mode 100644 prototypes/connections/results/2026-09-23-execution-core/README.md create mode 100644 prototypes/connections/results/2026-09-23-execution-core/native.json create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/Makefile create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/check.py create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/evaluate.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/excerpt.pure create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/select-text.py create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/selection.pure create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/storage.pure create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.h create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.h create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/result.h create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.h create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/server.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.h create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.h create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-fields.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-program.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-runtime.c create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/compiler.mjs create mode 100755 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/connections/results/2026-09-23-execution-core/verification.json create mode 100644 prototypes/connections/results/2026-09-23-field-connections/README.md create mode 100644 prototypes/connections/results/2026-09-23-field-connections/native.json create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/Makefile create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/check.py create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/excerpt.pure create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/select-text.py create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/selection.pure create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/storage.pure create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.h create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.h create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/server.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.h create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-fields.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-program.c create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/compiler.mjs create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/connections/results/2026-09-23-field-connections/verification.json create mode 100644 prototypes/connections/results/2026-09-23-native-connections/README.md create mode 100644 prototypes/connections/results/2026-09-23-native-connections/native.json create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.test.mjs create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/compiler.mjs create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/connections/results/2026-09-23-native-connections/verification.json create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/README.md create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/native.json create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sanitized.json create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/Makefile create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/check.py create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/excerpt.pure create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/storage.pure create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.c create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.h create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.c create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.h create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/server.c create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.c create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.h create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-flow.c create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-program.c create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/compiler.mjs create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/connections/results/2026-09-23-prepared-connections/verification.json create mode 100644 prototypes/connections/results/2026-09-24-async-effects/async-core.json create mode 100644 prototypes/connections/results/2026-09-24-async-effects/default-core.json create mode 100644 prototypes/connections/results/2026-09-24-async-effects/flow.json create mode 100644 prototypes/connections/results/2026-09-24-async-effects/sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-async-http/full-check.json create mode 100644 prototypes/connections/results/2026-09-24-async-http/gated.json create mode 100644 prototypes/connections/results/2026-09-24-async-http/profile.json create mode 100644 prototypes/connections/results/2026-09-24-async-http/sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-async-http/service-scale-memory.json create mode 100644 prototypes/connections/results/2026-09-24-async-read-write/full-check.json create mode 100644 prototypes/connections/results/2026-09-24-async-read-write/gated.json create mode 100644 prototypes/connections/results/2026-09-24-async-read-write/profile.json create mode 100644 prototypes/connections/results/2026-09-24-async-read-write/sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-async-read-write/service-scale.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/policy-peer.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/policy.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-atomic-policy/source-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/cli-check.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/cli-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/cli-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/cli.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/core.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/default-profile.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/fixed-memory.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/source-cli-check.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/source-cli.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/tui-profile.json create mode 100644 prototypes/connections/results/2026-09-24-compact-runtime/tui.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range-source/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-copy-range-source/source-check-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/copy-path.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/default-core.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/default-fixed-memory.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/default-fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/default-functional.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/default-memory.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/default-profile.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/functional.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/memory.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/profile.json create mode 100644 prototypes/connections/results/2026-09-24-copy-range/service-scale.json create mode 100644 prototypes/connections/results/2026-09-24-document-peer/clean-peer.log create mode 100644 prototypes/connections/results/2026-09-24-document-peer/clean-release.log create mode 100644 prototypes/connections/results/2026-09-24-document-peer/dynamic.txt create mode 100644 prototypes/connections/results/2026-09-24-document-peer/file-sizes.txt create mode 100644 prototypes/connections/results/2026-09-24-document-peer/hashes.txt create mode 100644 prototypes/connections/results/2026-09-24-document-peer/package-check.json create mode 100644 prototypes/connections/results/2026-09-24-document-peer/package-hashes.txt create mode 100644 prototypes/connections/results/2026-09-24-document-peer/peer-budget.json create mode 100644 prototypes/connections/results/2026-09-24-document-peer/peer-flow.json create mode 100644 prototypes/connections/results/2026-09-24-document-peer/peer-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-document-peer/pure-document-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-document-peer/scalar-refactor.json create mode 100644 prototypes/connections/results/2026-09-24-document-peer/size.txt create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/backpressure.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/final.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/package.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/parity.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/peak-memory.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/pure-document-fixed-http-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/source-profile.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-http/verified.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/memory-final.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/memory-verified.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/memory.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/package-final.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/package.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/parity-final-v2.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/parity-final.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/parity-reconfigured.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/parity-release.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/parity-verified.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/parity.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/profile-final.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/profile.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-final-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-fixed-program/window-regression.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui-final/package.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui-final/profile.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui-final/pty-flow.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui-final/pty-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui-final/pure-document-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/package-cursor.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/package.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/profile.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-flow.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/pty-flow.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/pty-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-fixed-tui/pure-document-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-flat-export-source/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-flat-export-source/source-check-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-flat-export/export-check.json create mode 100644 prototypes/connections/results/2026-09-24-flat-export/export-ext4.json create mode 100644 prototypes/connections/results/2026-09-24-flat-export/export-scale-1024.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-asan.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-release.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/memory.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-full.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-micro.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/peer.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/profile.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-authoring-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-micro-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/small-scan.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit-asan.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage-v2/write-cost.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/app-asan.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/app-release.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/memory.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/package-full.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/package-micro.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/peer.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/profile.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-authoring-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-micro-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/unit-asan.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/unit.json create mode 100644 prototypes/connections/results/2026-09-24-hybrid-storage/write-cost.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/archived-comparison.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/fixed-server-regression.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/idle.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/package.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/pure-document-fixed-http-idle-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/reproducible-comparison.json create mode 100644 prototypes/connections/results/2026-09-24-idle-wake/source-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/async-core.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/async-gated.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/async-server-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-check.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-check.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-http.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/cli.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/core.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/default-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/document-peer-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/document-peer.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/fixed-memory.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/full-check.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/package-reproducibility.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/precompact-budget-failure.txt create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/release.log create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/runtime-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/service-scale.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/source-cli-check.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/source-cli.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/source-memory.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/tui-profile.json create mode 100644 prototypes/connections/results/2026-09-24-idle-work-binding/tui.json create mode 100644 prototypes/connections/results/2026-09-24-in-process-routing/mirror-ext4.json create mode 100644 prototypes/connections/results/2026-09-24-in-process-routing/mirror-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-in-process-routing/mirror-tmp.json create mode 100644 prototypes/connections/results/2026-09-24-micro-default/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-micro-default/source-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli-check.json create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http-check.json create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-micro-profile/default-host-budget.json create mode 100644 prototypes/connections/results/2026-09-24-mirror-source/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-mirror-source/source-check-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-final.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-repeated.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-final.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-repeated.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/hybrid-gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-recheck.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-repeated.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/package-final.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/package-repeated.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/package.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-final-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-fresh-buffer.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-initial.json create mode 100644 prototypes/connections/results/2026-09-24-multi-document-window/window-profile.json create mode 100644 prototypes/connections/results/2026-09-24-native-checkpoint/fixed-memory.json create mode 100644 prototypes/connections/results/2026-09-24-native-checkpoint/fixed-tui.json create mode 100644 prototypes/connections/results/2026-09-24-native-checkpoint/source-memory.json create mode 100644 prototypes/connections/results/2026-09-24-native-checkpoint/source-tui.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/full-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow-final.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized-final.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/http-only-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/micro-memory.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/micro-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/peer-only-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-authoring-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-http-only-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-micro-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-peer-only-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-window-only-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/window-authoring.png create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/window-budget.json create mode 100644 prototypes/connections/results/2026-09-24-native-definition-authoring/window-only-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/build.json create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/connection-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/core-budget.json create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/dependencies.txt create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/gtk-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/size.txt create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/stripped.txt create mode 100644 prototypes/connections/results/2026-09-24-native-document-window/window.png create mode 100644 prototypes/connections/results/2026-09-24-native-save/fixed-cli-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/fixed-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/flat-fixed-tui.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/flat-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/flat-source-tui.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/hybrid-fixed-tui.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/hybrid-source-tui.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-save/pure-document-flat-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-save/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-save/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-save/source-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-native-source/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-native-source/source-check-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-opaque-core/source-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/app-asan.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/app-release.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/gtk-flow.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/memory.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/package-full.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/package-micro.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/paged-profile.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/peer.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-authoring-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-micro-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/unit-asan.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/unit.json create mode 100644 prototypes/connections/results/2026-09-24-paged-storage/write-cost.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer-source/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-portable-peer-source/source-check-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-rss-regression.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer/client-server-profile.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-rss-regression.json create mode 100644 prototypes/connections/results/2026-09-24-portable-peer/server-only-profile.json create mode 100644 prototypes/connections/results/2026-09-24-portable-source/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-portable-source/source-check-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-rss-regression.json create mode 100644 prototypes/connections/results/2026-09-24-portable-tui/source-linux-aarch64.json create mode 100644 prototypes/connections/results/2026-09-24-portable-tui/source-linux-rss-regression.json create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/fixed-server-regression.json create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/package.json create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/pure-document-fixed-http-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/reproducible-comparison.json create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/service-scale.json create mode 100644 prototypes/connections/results/2026-09-24-posix-idle/source-profile.json create mode 100644 prototypes/connections/results/2026-09-24-prefix-find/cli-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-prefix-find/package-check.json create mode 100644 prototypes/connections/results/2026-09-24-prefix-find/tui-profile.json create mode 100644 prototypes/connections/results/2026-09-24-prefix-find/tui.json create mode 100644 prototypes/connections/results/2026-09-24-release-callback/async-core.json create mode 100644 prototypes/connections/results/2026-09-24-release-callback/core.json create mode 100644 prototypes/connections/results/2026-09-24-release-callback/micro-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-release-callback/pure-document-micro-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-release-callback/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-release-callback/runtime.json create mode 100644 prototypes/connections/results/2026-09-24-release-callback/source-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-server-only-package/combined-peer-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-server-only-package/pure-document-combined-peer-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-server-only-package/pure-document-server-only-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-server-only-package/server-only-package-check.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-profile.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-server-regression.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/package.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/pure-document-fixed-http-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/reproducible-comparison.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale-restored.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale.json create mode 100644 prototypes/connections/results/2026-09-24-signal-safe-idle/source-profile.json create mode 100644 prototypes/connections/results/2026-09-24-source-preview/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-source-preview/source-check.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/core.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-source-tui/fixed-tui-profile.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-source-tui/source-check.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/source-cli-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/source-cli-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-source-tui/tui-profile.json create mode 100644 prototypes/connections/results/2026-09-24-source-tui/tui.json create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/cli-check.json create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui-check.json create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/cli.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/profile.json create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-terminal-document-list/tui.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/latency-baseline.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/latency-current.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/package-http-tui.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/package.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/profile.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/pty-flow.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/pty-sanitized.json create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-http-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/final-native-window.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/line-width-4096.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/mallinfo.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/no-document-undo.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/no-key-undo.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/no-wrap.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/replace-view.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/same-body.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/same-key.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/settled-final.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/skip-text.json create mode 100644 prototypes/connections/results/2026-09-24-window-memory-analysis/smaps.json create mode 100644 prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-tui-linux-aarch64.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-work-slot/pure-source-preview.tar.gz create mode 100644 prototypes/connections/results/2026-09-24-work-slot/source-package-check.json create mode 100644 prototypes/connections/runtime.c create mode 100644 prototypes/connections/runtime.h create mode 100644 prototypes/connections/server.c create mode 100644 prototypes/connections/source-tui-budget.json create mode 100644 prototypes/connections/store-paged.c create mode 100644 prototypes/connections/store-port.c create mode 100644 prototypes/connections/store-port.h create mode 100644 prototypes/connections/store.c create mode 100644 prototypes/connections/store.h create mode 100644 prototypes/connections/test-async-server-gate.c create mode 100644 prototypes/connections/test-async.c create mode 100644 prototypes/connections/test-fields.c create mode 100644 prototypes/connections/test-flow.c create mode 100644 prototypes/connections/test-paged-store.c create mode 100644 prototypes/connections/test-program.c create mode 100644 prototypes/connections/test-runtime.c create mode 100644 prototypes/connections/test-splice.c create mode 100644 prototypes/connections/tui-budget.json create mode 100644 prototypes/connections/window-budget.json create mode 100644 prototypes/forgetful-core/README.md create mode 100644 prototypes/forgetful-core/check.py create mode 100644 prototypes/forgetful-core/core.c create mode 100644 prototypes/forgetful-core/core.h create mode 100644 prototypes/forgetful-core/test.c create mode 100644 prototypes/forgetful-core/verification.json create mode 100644 prototypes/live-rules/.gitignore create mode 100644 prototypes/live-rules/README.md create mode 100644 prototypes/live-rules/adapter.test.mjs create mode 100644 prototypes/live-rules/cli.mjs create mode 100644 prototypes/live-rules/core.zig create mode 100644 prototypes/live-rules/manage.py create mode 100644 prototypes/live-rules/measure.mjs create mode 100644 prototypes/live-rules/qa-browser.js create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png create mode 100644 prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json create mode 100644 prototypes/live-rules/runtime.mjs create mode 100644 prototypes/live-rules/web/app.js create mode 100755 prototypes/live-rules/web/core.wasm create mode 100644 prototypes/live-rules/web/index.html create mode 100644 prototypes/live-rules/web/style.css create mode 100644 prototypes/workbench/.gitignore create mode 100644 prototypes/workbench/CONTRACT.md create mode 100644 prototypes/workbench/README.md create mode 100644 prototypes/workbench/baseline.test.mjs create mode 100644 prototypes/workbench/cli.mjs create mode 100644 prototypes/workbench/cli.test.mjs create mode 100644 prototypes/workbench/compare.mjs create mode 100644 prototypes/workbench/compiler.test.mjs create mode 100644 prototypes/workbench/core.zig create mode 100644 prototypes/workbench/engine.zig create mode 100644 prototypes/workbench/evaluation-check.mjs create mode 100644 prototypes/workbench/evaluation.test.mjs create mode 100644 prototypes/workbench/evaluation.zig create mode 100644 prototypes/workbench/examples/charge.pure create mode 100644 prototypes/workbench/examples/counter.pure create mode 100644 prototypes/workbench/examples/direct.pure create mode 100644 prototypes/workbench/examples/document.pure create mode 100644 prototypes/workbench/examples/layout.pure create mode 100644 prototypes/workbench/examples/routed-layout.pure create mode 100644 prototypes/workbench/examples/shared-layout.pure create mode 100644 prototypes/workbench/examples/toggle.pure create mode 100644 prototypes/workbench/manage.py create mode 100644 prototypes/workbench/native-fixed.zig create mode 100644 prototypes/workbench/native.zig create mode 100644 prototypes/workbench/native/README.md create mode 100644 prototypes/workbench/native/app-memory.c create mode 100644 prototypes/workbench/native/app.c create mode 100644 prototypes/workbench/native/app.h create mode 100644 prototypes/workbench/native/check.mjs create mode 100644 prototypes/workbench/native/check_app_memory.py create mode 100644 prototypes/workbench/native/desktop.c create mode 100644 prototypes/workbench/native/instance-memory.c create mode 100644 prototypes/workbench/native/main.c create mode 100644 prototypes/workbench/native/peer-check.py create mode 100644 prototypes/workbench/native/peer-wire-test.c create mode 100644 prototypes/workbench/native/peer-wire.c create mode 100644 prototypes/workbench/native/peer-wire.h create mode 100644 prototypes/workbench/native/peer.c create mode 100644 prototypes/workbench/native/plan.c create mode 100644 prototypes/workbench/native/plan.h create mode 100644 prototypes/workbench/native/prepare.mjs create mode 100644 prototypes/workbench/native/scalar.h create mode 100644 prototypes/workbench/native/test-fixed.c create mode 100644 prototypes/workbench/native/test.c create mode 100644 prototypes/workbench/pointer.test.mjs create mode 100644 prototypes/workbench/presentation.test.mjs create mode 100644 prototypes/workbench/profile-refresh.js create mode 100644 prototypes/workbench/project.test.mjs create mode 100644 prototypes/workbench/qa-browser.js create mode 100644 prototypes/workbench/qa-document-guards.js create mode 100644 prototypes/workbench/qa-document.js create mode 100644 prototypes/workbench/qa-evaluation.js create mode 100644 prototypes/workbench/qa-layout.js create mode 100644 prototypes/workbench/qa-pointer.js create mode 100644 prototypes/workbench/qa-presentation.js create mode 100644 prototypes/workbench/qa-presentation.py create mode 100644 prototypes/workbench/qa-refresh.js create mode 100644 prototypes/workbench/qa-refresh.py create mode 100644 prototypes/workbench/qa-release.js create mode 100644 prototypes/workbench/qa-routed-input.js create mode 100644 prototypes/workbench/qa-shared-rules.js create mode 100644 prototypes/workbench/remote.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/browser.json create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/comparison.json create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/baseline.test.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/cli.test.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/compiler.test.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/core.zig create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation-check.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.test.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/examples/charge.pure create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/examples/counter.pure create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/examples/toggle.pure create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/manage.py create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/manifest.json create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/qa-evaluation.js create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/runtime.test.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/web/app.js create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/web/baseline.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/web/compiler.mjs create mode 100755 prototypes/workbench/results/2026-09-22-evaluation/sources/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/sources/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-22-evaluation/verification.json create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/authored.png create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/blank.png create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/browser-checks.json create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/comparison.json create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/exported-draft.json create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/mobile.png create mode 100644 prototypes/workbench/results/2026-09-22-linux-aarch64/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-layout/README.md create mode 100644 prototypes/workbench/results/2026-09-23-layout/authoring-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-layout/build.json create mode 100644 prototypes/workbench/results/2026-09-23-layout/layout-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-layout/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-layout/pure-layout-desktop.png create mode 100644 prototypes/workbench/results/2026-09-23-layout/pure-layout-mobile.png create mode 100644 prototypes/workbench/results/2026-09-23-layout/pure-layout-saved.json create mode 100644 prototypes/workbench/results/2026-09-23-layout/qa-browser.js create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/CONTRACT.md create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/cli.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/examples/layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/project.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/qa-layout.js create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/web/project.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/web/scene-input.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/sources/workspace.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-layout/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/README.md create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/application-build.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/auxiliary-sources.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/browser-build.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/browser-tests.log create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/default-linkage.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/native.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sanitized.json create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/evaluate.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/flow.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/program.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/result.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/runtime.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store-port.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/connections/store.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/baseline.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/cli.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compare.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/compiler.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/engine.zig create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation-check.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/charge.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/counter.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/direct.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/document.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/routed-layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/shared-layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/examples/toggle.pure create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/manage.py create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native.zig create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/app.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/check.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/main.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/plan.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/prepare.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/scalar.h create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/native/test.c create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/pointer.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/presentation.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/project.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/remote.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/routing.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/rules.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/runtime.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/value-input.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/baseline.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/project.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace-check.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/workspace.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-native-instances/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/README.md create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/authoring-result.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/authoring-run.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/authoring.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/before/compiler.mjs create mode 100755 prototypes/workbench/results/2026-09-23-pointer-routing/before/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/before/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/before/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/browser-initial-failure.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/browser-result.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/browser-run.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/browser.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/build.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/legacy-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/legacy-result.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/legacy-run.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/node-tests.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-desktop.png create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-mobile.png create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-saved.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/qa-authoring.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/qa-legacy-pointer.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/representation.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/CONTRACT.md create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/examples/routed-layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/presentation.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/qa-routed-input.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/routing.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/output.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/scene-input.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/sources/workspace.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/touch-evidence.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/touch-reproduction.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/touch-run.log create mode 100644 prototypes/workbench/results/2026-09-23-pointer-routing/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer/README.md create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/CONTRACT.md create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/cli.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/examples/direct.pure create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/pointer.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/qa-pointer.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/output.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/scene-input.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/after/web/style.css create mode 100644 prototypes/workbench/results/2026-09-23-pointer/authoring-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/output.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-pointer/before/web/style.css create mode 100644 prototypes/workbench/results/2026-09-23-pointer/build.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer/pointer-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-pointer/pure-pointer-desktop.png create mode 100644 prototypes/workbench/results/2026-09-23-pointer/pure-pointer-mobile.png create mode 100644 prototypes/workbench/results/2026-09-23-pointer/qa-browser.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer/qa-pointer.js create mode 100644 prototypes/workbench/results/2026-09-23-pointer/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-presentation/before/web/style.css create mode 100644 prototypes/workbench/results/2026-09-23-presentation/browser-authoring.json create mode 100644 prototypes/workbench/results/2026-09-23-presentation/browser-limit.json create mode 100644 prototypes/workbench/results/2026-09-23-presentation/browser.json create mode 100644 prototypes/workbench/results/2026-09-23-presentation/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/presentation.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.js create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/qa-presentation.py create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/output.mjs create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-refresh/before/web/style.css create mode 100644 prototypes/workbench/results/2026-09-23-refresh/browser-authoring.json create mode 100644 prototypes/workbench/results/2026-09-23-refresh/browser-limit.json create mode 100644 prototypes/workbench/results/2026-09-23-refresh/browser.json create mode 100644 prototypes/workbench/results/2026-09-23-refresh/profile-before.json create mode 100644 prototypes/workbench/results/2026-09-23-refresh/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-release/README.md create mode 100644 prototypes/workbench/results/2026-09-23-release/after-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-release/after/cli.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-release/after/examples/direct.pure create mode 100644 prototypes/workbench/results/2026-09-23-release/after/pointer.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-release/after/qa-pointer.js create mode 100644 prototypes/workbench/results/2026-09-23-release/after/qa-release.js create mode 100644 prototypes/workbench/results/2026-09-23-release/after/web/examples/direct.pure create mode 100644 prototypes/workbench/results/2026-09-23-release/after/web/scene-input.mjs create mode 100644 prototypes/workbench/results/2026-09-23-release/before-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-release/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-release/pointer-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/README.md create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/authoring-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/before/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/before/examples/layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/before/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/before/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/before/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/build.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-desktop.png create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-mobile.png create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-saved.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/qa-browser.js create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/representation.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/shared-browser.json create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/CONTRACT.md create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/examples/shared-layout.pure create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/qa-shared-rules.js create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/rules.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/runtime.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/project.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/web/scene-input.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/sources/workspace.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-shared-rules/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/README.md create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/authoring-result.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/authoring-run.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/authoring.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/before/compiler.mjs create mode 100755 prototypes/workbench/results/2026-09-23-web-layout/before/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/before/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/before/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/browser-result.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/browser-run.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/browser.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/build.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/compiler-check.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/guards-result.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/guards-run.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/guards.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/manifest.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/native-build.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/node-tests.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/pure-document-desktop.png create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/pure-document-mobile.png create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/pure-document-saved.txt create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/qa-authoring.js create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/qa-value-control.js create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/CONTRACT.md create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/cli.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/core.zig create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/evaluation.zig create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/examples/document.pure create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document-guards.js create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/qa-document.js create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/value-input.test.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/app.js create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/compiler.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/core.wasm create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.css create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.html create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/document.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/index.html create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/runtime.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/sources/web/split-input.mjs create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/value-control.json create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/value-control.log create mode 100644 prototypes/workbench/results/2026-09-23-web-layout/verification.json create mode 100644 prototypes/workbench/results/2026-09-23-workspace/browser.json create mode 100644 prototypes/workbench/results/2026-09-23-workspace/comparison.json create mode 100644 prototypes/workbench/results/2026-09-23-workspace/verification.json create mode 100644 prototypes/workbench/results/2026-09-24-compact-runtime/app-memory-three-way-final.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-cli-build.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-parity-final.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/fixed-peer-build.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/general-parity-final.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/instance-memory.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/peer-check.json create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/size.txt create mode 100644 prototypes/workbench/results/2026-09-24-fixed-capacity/stripped.txt create mode 100644 prototypes/workbench/results/2026-09-24-idle-work-binding/app-memory.json create mode 100644 prototypes/workbench/results/2026-09-24-idle-work-binding/native-sanitized.json create mode 100644 prototypes/workbench/results/2026-09-24-idle-work-binding/native-unit.json create mode 100644 prototypes/workbench/results/2026-09-24-idle-work-binding/scalar-peer.json create mode 100644 prototypes/workbench/results/2026-09-24-idle-work-binding/stack-baseline.su create mode 100644 prototypes/workbench/results/2026-09-24-idle-work-binding/stack-elastic.su create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/README.md create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/authored-after.pure create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/capture-x11.c create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/core-budget.json create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/drag-and-save.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/manifest.json create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/native-parity-sanitized.json create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/native-parity.json create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/numeric-input.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/recovered-edit.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/rejected-edit.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/reopened-state.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/resized-input.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/resized.png create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/sources/manage.py create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.c create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/sources/native/app.h create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/sources/native/check.mjs create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/sources/native/desktop.c create mode 100644 prototypes/workbench/results/2026-09-24-native-desktop/sources/native/test.c create mode 100644 prototypes/workbench/results/2026-09-24-secure-peer/build.json create mode 100644 prototypes/workbench/results/2026-09-24-secure-peer/peer-check.json create mode 100644 prototypes/workbench/results/2026-09-24-secure-peer/size.txt create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/cli-build.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/dependencies.txt create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/desktop-build.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/fixed-parity.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/general-parity.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/instance-memory.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/peer-build.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/peer-check.json create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/size.txt create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/stripped.txt create mode 100644 prototypes/workbench/results/2026-09-24-shared-program/symbols.txt create mode 100644 prototypes/workbench/routing.test.mjs create mode 100644 prototypes/workbench/rules.test.mjs create mode 100644 prototypes/workbench/runtime.test.mjs create mode 100644 prototypes/workbench/value-input.test.mjs create mode 100644 prototypes/workbench/web/app.js create mode 100644 prototypes/workbench/web/baseline.html create mode 100644 prototypes/workbench/web/baseline.mjs create mode 100644 prototypes/workbench/web/compiler.mjs create mode 100755 prototypes/workbench/web/core.wasm create mode 100644 prototypes/workbench/web/document.css create mode 100644 prototypes/workbench/web/document.html create mode 100644 prototypes/workbench/web/document.mjs create mode 100644 prototypes/workbench/web/index.html create mode 100644 prototypes/workbench/web/output.mjs create mode 100644 prototypes/workbench/web/project.mjs create mode 100644 prototypes/workbench/web/runtime.mjs create mode 100644 prototypes/workbench/web/scene-input.mjs create mode 100644 prototypes/workbench/web/split-input.mjs create mode 100644 prototypes/workbench/web/style.css create mode 100644 prototypes/workbench/workspace-check.mjs create mode 100644 prototypes/workbench/workspace.test.mjs (limited to 'prototypes') diff --git a/prototypes/common/tls-link.c b/prototypes/common/tls-link.c new file mode 100644 index 0000000..adfaac2 --- /dev/null +++ b/prototypes/common/tls-link.c @@ -0,0 +1,217 @@ +#define _POSIX_C_SOURCE 200809L +#include "tls-link.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +uint64_t pure_link_now_ms(void) { + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts)) abort(); + return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; +} +static bool ready(int fd, short events, uint64_t deadline) { + for (;;) { + uint64_t now = pure_link_now_ms(); + if (now >= deadline) return false; + int remaining = (int)(deadline - now); + struct pollfd p = {.fd = fd, .events = events}; + int result = poll(&p, 1, remaining); + if (result > 0) return !(p.revents & (POLLERR | POLLNVAL)) && (p.revents & (events | POLLHUP)); + if (!result) return false; + if (errno != EINTR) return false; + } +} +bool pure_link_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} +static bool tls_retry(PureLink *channel, int code, uint64_t deadline) { + if (code == SSL_ERROR_WANT_READ) return ready(channel->fd, POLLIN, deadline); + if (code == SSL_ERROR_WANT_WRITE) return ready(channel->fd, POLLOUT, deadline); + return false; +} +int pure_link_read(PureLink *channel, void *output, size_t size, uint64_t deadline) { + size_t at = 0; + while (at < size) { + if (pure_link_now_ms() >= deadline) return -1; + size_t received = 0; + ERR_clear_error(); + int result = SSL_read_ex(channel->ssl, (unsigned char *)output + at, size - at, &received); + if (result == 1) { if (!received) return -1; at += received; continue; } + int code = SSL_get_error(channel->ssl, result); + if (code == SSL_ERROR_ZERO_RETURN && !at) return 0; + if (!tls_retry(channel, code, deadline)) return -1; + } + return 1; +} +bool pure_link_write(PureLink *channel, const void *input, size_t size, uint64_t deadline) { + size_t at = 0; + while (at < size) { + if (pure_link_now_ms() >= deadline) return false; + size_t sent = 0; + size_t chunk = size - at; + if (chunk > 16384) chunk = 16384; + ERR_clear_error(); + int result = SSL_write_ex(channel->ssl, (const unsigned char *)input + at, chunk, &sent); + if (result == 1) { if (!sent) return false; at += sent; continue; } + int code = SSL_get_error(channel->ssl, result); + if (!tls_retry(channel, code, deadline)) return false; + } + return true; +} +int pure_link_read_frame(PureLink *channel, unsigned char *output, size_t capacity, + size_t *size, uint64_t deadline) { + unsigned char prefix[2]; + int first = pure_link_read(channel, prefix, 2, deadline); + if (first != 1) return first; + size_t length = ((size_t)prefix[0] << 8) | prefix[1]; + if (!length || length > capacity || pure_link_read(channel, output, length, deadline) != 1) return -1; + *size = length; + return 1; +} +bool pure_link_write_frame(PureLink *channel, const unsigned char *data, size_t size, + uint64_t deadline) { + if (!size || size > UINT16_MAX) return false; + unsigned char prefix[2] = {(unsigned char)(size >> 8), (unsigned char)size}; + return pure_link_write(channel, prefix, 2, deadline) && pure_link_write(channel, data, size, deadline); +} +void pure_link_close(PureLink *channel, bool healthy) { + if (channel->ssl) { + if (healthy) (void)SSL_shutdown(channel->ssl); + SSL_free(channel->ssl); + } + if (channel->fd >= 0) close(channel->fd); + *channel = (PureLink){.fd = -1}; +} +static int select_alpn(SSL *ssl, const unsigned char **out, unsigned char *outlen, + const unsigned char *in, unsigned int length, void *arg) { + (void)ssl; + const unsigned char *alpn = arg; + for (unsigned int at = 0; at < length;) { + unsigned int n = in[at++]; + if (!n || n > length - at) return SSL_TLSEXT_ERR_ALERT_FATAL; + if (n == alpn[0] && !memcmp(in + at, alpn + 1, n)) { + *out = alpn + 1; *outlen = alpn[0]; return SSL_TLSEXT_ERR_OK; + } + at += n; + } + return SSL_TLSEXT_ERR_ALERT_FATAL; +} +SSL_CTX *pure_link_context(bool server, const char *ca, const char *cert, + const char *key, const unsigned char *alpn) { + SSL_CTX *ctx = SSL_CTX_new(server ? TLS_server_method() : TLS_client_method()); + if (!ctx) return NULL; + if (!alpn || !alpn[0] || !SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION) || + !SSL_CTX_set_max_proto_version(ctx, TLS1_3_VERSION) || + !SSL_CTX_set_max_early_data(ctx, 0) || + !SSL_CTX_load_verify_locations(ctx, ca, NULL) || + SSL_CTX_use_certificate_chain_file(ctx, cert) != 1 || + SSL_CTX_use_PrivateKey_file(ctx, key, SSL_FILETYPE_PEM) != 1 || + SSL_CTX_check_private_key(ctx) != 1) { + SSL_CTX_free(ctx); return NULL; + } + SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | (server ? SSL_VERIFY_FAIL_IF_NO_PEER_CERT : 0), NULL); + if (server) SSL_CTX_set_alpn_select_cb(ctx, select_alpn, (void *)alpn); + return ctx; +} +bool pure_link_handshake(PureLink *channel, SSL_CTX *ctx, bool server, + const char *peer_name, const unsigned char *alpn) { + /* This protocol writes a small reply header followed by a separate TLS + * body record. Disable Nagle on the connected TCP socket so that the + * header cannot hold the body behind a delayed acknowledgement. */ + int enabled = 1; + if (setsockopt(channel->fd, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled))) return false; + channel->ssl = SSL_new(ctx); + if (!channel->ssl || SSL_set_fd(channel->ssl, channel->fd) != 1) return false; + if (server) SSL_set_accept_state(channel->ssl); + else { + SSL_set_connect_state(channel->ssl); + SSL_set_hostflags(channel->ssl, X509_CHECK_FLAG_NO_WILDCARDS | X509_CHECK_FLAG_NEVER_CHECK_SUBJECT); + if (SSL_set1_host(channel->ssl, peer_name) != 1 || + SSL_set_tlsext_host_name(channel->ssl, peer_name) != 1 || + SSL_set_alpn_protos(channel->ssl, alpn, alpn[0] + 1u) != 0) return false; + } + uint64_t deadline = pure_link_now_ms() + PURE_LINK_DEADLINE_MS; + for (;;) { + ERR_clear_error(); + int result = SSL_do_handshake(channel->ssl); + if (result == 1) break; + int code = SSL_get_error(channel->ssl, result); + if (!tls_retry(channel, code, deadline)) return false; + } + if (SSL_version(channel->ssl) != TLS1_3_VERSION || SSL_get_verify_result(channel->ssl) != X509_V_OK) + return false; + X509 *peer = SSL_get1_peer_certificate(channel->ssl); + if (!peer) return false; + bool verified = !server || X509_check_host(peer, peer_name, 0, + X509_CHECK_FLAG_NO_WILDCARDS | X509_CHECK_FLAG_NEVER_CHECK_SUBJECT, NULL) == 1; + X509_free(peer); + const unsigned char *chosen = NULL; + unsigned int length = 0; + SSL_get0_alpn_selected(channel->ssl, &chosen, &length); + return verified && length == alpn[0] && !memcmp(chosen, alpn + 1, length); +} +bool pure_link_port_number(const char *text, bool allow_zero) { + if (!text || !*text) return false; + unsigned long value = 0; + for (const char *p = text; *p; ++p) { + if (*p < '0' || *p > '9') return false; + unsigned digit = (unsigned)(*p - '0'); + if (value > (65535ul - digit) / 10) return false; + value = value * 10 + digit; + } + return allow_zero || value != 0; +} +int pure_link_listen_at(const char *host, const char *port) { + struct addrinfo hints = {.ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM, + .ai_flags = AI_PASSIVE | AI_NUMERICHOST}; + struct addrinfo *addresses = NULL; + if (getaddrinfo(host, port, &hints, &addresses)) return -1; + int listener = -1; + for (struct addrinfo *it = addresses; it; it = it->ai_next) { + int fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol); + if (fd < 0) continue; + int enabled = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)); + if (bind(fd, it->ai_addr, it->ai_addrlen) == 0 && listen(fd, 32) == 0 && pure_link_nonblocking(fd)) { + listener = fd; break; + } + close(fd); + } + freeaddrinfo(addresses); + return listener; +} +int pure_link_connect_to(const char *host, const char *port) { + struct addrinfo hints = {.ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM}; + struct addrinfo *addresses = NULL; + if (getaddrinfo(host, port, &hints, &addresses)) return -1; + int connected = -1; + uint64_t deadline = pure_link_now_ms() + PURE_LINK_DEADLINE_MS; + for (struct addrinfo *it = addresses; it; it = it->ai_next) { + int fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol); + if (fd < 0) continue; + if (pure_link_nonblocking(fd)) { + int result = connect(fd, it->ai_addr, it->ai_addrlen); + if (result == 0 || (result < 0 && errno == EINPROGRESS && ready(fd, POLLOUT, deadline))) { + int error = 0; socklen_t size = sizeof(error); + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &size) == 0 && !error) { + connected = fd; break; + } + } + } + close(fd); + } + freeaddrinfo(addresses); + return connected; +} diff --git a/prototypes/common/tls-link.h b/prototypes/common/tls-link.h new file mode 100644 index 0000000..53e05ff --- /dev/null +++ b/prototypes/common/tls-link.h @@ -0,0 +1,32 @@ +#ifndef PURE_TLS_LINK_H +#define PURE_TLS_LINK_H +#include +#include +#include +#include + +enum { PURE_LINK_DEADLINE_MS = 5000 }; +typedef struct { int fd; SSL *ssl; } PureLink; + +uint64_t pure_link_now_ms(void); +bool pure_link_nonblocking(int fd); +/* read returns 1 for all bytes, 0 for clean EOF before any byte, -1 for an + * incomplete frame, TLS failure or deadline. Never treat -1 as an EOF. */ +int pure_link_read(PureLink *, void *output, size_t size, uint64_t deadline); +bool pure_link_write(PureLink *, const void *input, size_t size, uint64_t deadline); +int pure_link_read_frame(PureLink *, unsigned char *output, size_t capacity, + size_t *size, uint64_t deadline); +bool pure_link_write_frame(PureLink *, const unsigned char *data, size_t size, + uint64_t deadline); +void pure_link_close(PureLink *, bool healthy); + +/* alpn is a static length-prefixed protocol name, retained by SSL_CTX's + * callback until the context is freed. Its content has exactly alpn[0]+1 bytes. */ +SSL_CTX *pure_link_context(bool server, const char *ca, const char *cert, + const char *key, const unsigned char *alpn); +bool pure_link_handshake(PureLink *, SSL_CTX *, bool server, const char *peer_name, + const unsigned char *alpn); +bool pure_link_port_number(const char *text, bool allow_zero); +int pure_link_listen_at(const char *host, const char *port); +int pure_link_connect_to(const char *host, const char *port); +#endif diff --git a/prototypes/connections/.gitignore b/prototypes/connections/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/prototypes/connections/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/prototypes/connections/Makefile b/prototypes/connections/Makefile new file mode 100644 index 0000000..502d359 --- /dev/null +++ b/prototypes/connections/Makefile @@ -0,0 +1,251 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build + +.PHONY: all core async-core editor fixed-editor fixed-server fixed-tui source-tui compiler mirror window server async-server document-peer document-server check check-policy check-policy-peer check-portable check-portable-tui check-portable-peer check-native-checkpoint check-mirror check-core check-async check-async-server check-async-service-scale check-profile check-fixed-profile check-fixed-server check-service-scale check-tui-profile check-source-tui-profile check-tui check-source-tui check-release check-micro-memory check-hybrid check-flat-export check-copy-range check-peer-profile check-document-server-profile check-window-profile check-document-peer check-document-server FORCE +CORE = runtime.c +STORE ?= store.c +FIXED_PROGRAM ?= examples/editor.pure +HOST = $(STORE) store-port.c evaluate.c +HEADERS = runtime.h program.h store.h store-port.h flow.h result.h +all: core fixed-editor +core: $(BUILD)/libpure-execution.a +async-core: $(BUILD)/libpure-execution-async.a +editor: $(BUILD)/pure-edit +fixed-editor: $(BUILD)/pure-edit-fixed +fixed-server: $(BUILD)/pure-connections-fixed +fixed-tui: $(BUILD)/pure-edit-tui-fixed +source-tui: $(BUILD)/pure-edit-tui +compiler: $(BUILD)/pure-compile +mirror: $(BUILD)/pure-mirror +window: $(BUILD)/pure-edit-window +server: $(BUILD)/pure-connections +async-server: $(BUILD)/pure-connections-async +document-peer: $(BUILD)/pure-document-peer +document-server: $(BUILD)/pure-document-server + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c $(HOST) flow.c program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS server.c $(HOST) flow.c program.c $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/pure-connections-async: server.c $(HOST) flow.c program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS -DPURE_ASYNC_EFFECTS -DPURE_HTTP_ASYNC server.c $(HOST) flow.c program.c $(CORE) $(LDFLAGS) -pthread $(LDLIBS) -o $@ + +$(BUILD)/pure-connections-async-gated: server.c $(HOST) flow.c program.c $(CORE) test-async-server-gate.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS -DPURE_ASYNC_EFFECTS -DPURE_HTTP_ASYNC -DPURE_STORE_TEST server.c $(HOST) flow.c program.c $(CORE) test-async-server-gate.c $(LDFLAGS) -pthread $(LDLIBS) -o $@ + +$(BUILD)/pure-connections-fixed: server.c $(HOST) flow.c $(CORE) $(HEADERS) $(BUILD)/embedded-plan.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS -DPURE_EMBEDDED_PROGRAM -I$(BUILD) server.c $(HOST) flow.c $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/pure-edit: edit.c $(HOST) program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) edit.c $(HOST) program.c $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/pure-compile: compile-program.c program.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) compile-program.c program.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/embedded-plan.h: FORCE $(FIXED_PROGRAM) $(BUILD)/pure-compile + $(BUILD)/pure-compile $(FIXED_PROGRAM) $@ + +$(BUILD)/pure-edit-fixed: edit.c $(HOST) $(CORE) $(HEADERS) $(BUILD)/embedded-plan.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_EMBEDDED_PROGRAM -I$(BUILD) edit.c $(HOST) $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/storage-plan.h: FORCE examples/storage.pure $(BUILD)/pure-compile + $(BUILD)/pure-compile examples/storage.pure $@ + +$(BUILD)/pure-mirror: mirror.c $(HOST) $(CORE) $(HEADERS) $(BUILD)/storage-plan.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -I$(BUILD) mirror.c $(HOST) $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/pure-edit-tui-fixed: edit-tui.c $(HOST) $(CORE) $(HEADERS) $(BUILD)/embedded-plan.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_EMBEDDED_PROGRAM -I$(BUILD) edit-tui.c $(HOST) $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/pure-edit-tui: edit-tui.c $(HOST) $(CORE) program.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) edit-tui.c $(HOST) $(CORE) program.c $(LDFLAGS) $(LDLIBS) -o $@ + +# TLS/socket code is linked only when a document peer is explicitly selected. +$(BUILD)/pure-document-peer: document-peer.c ../common/tls-link.c ../common/tls-link.h $(HOST) program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) document-peer.c ../common/tls-link.c $(HOST) program.c $(CORE) $(LDFLAGS) -lssl -lcrypto $(LDLIBS) -o $@ + +$(BUILD)/pure-document-server: document-peer.c ../common/tls-link.c ../common/tls-link.h $(HOST) program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_PEER_SERVER_ONLY document-peer.c ../common/tls-link.c $(HOST) program.c $(CORE) $(LDFLAGS) -lssl -lcrypto $(LDLIBS) -o $@ + +# GTK is selected only for the document window. The core, CLI and server do +# not need its headers, libraries, display server or runtime dependencies. +$(BUILD)/pure-edit-window: edit-window.c $(HOST) program.c $(CORE) $(HEADERS) | $(BUILD) + pkg-config --atleast-version=4.10 gtk4 + $(CC) $(CPPFLAGS) $(CFLAGS) $$(pkg-config --cflags gtk4) edit-window.c $(HOST) program.c $(CORE) $(LDFLAGS) $$(pkg-config --libs gtk4) $(LDLIBS) -o $@ + +$(BUILD)/runtime.o: runtime.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c runtime.c -o $@ + +$(BUILD)/runtime-async.o: runtime.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_ASYNC_EFFECTS -c runtime.c -o $@ + +$(BUILD)/evaluate.o: evaluate.c program.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c evaluate.c -o $@ + +$(BUILD)/libpure-execution.a: $(BUILD)/runtime.o + rm -f $@ + $(AR) rcs $@ $^ + +$(BUILD)/libpure-execution-async.a: $(BUILD)/runtime-async.o + rm -f $@ + $(AR) rcs $@ $^ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-program: test-program.c program.c evaluate.c program.h store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-program.c program.c evaluate.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-fields: test-fields.c program.c evaluate.c program.h store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-fields.c program.c evaluate.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-runtime: test-runtime.c program.c $(CORE) $(HOST) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS test-runtime.c program.c $(CORE) $(HOST) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-async: test-async.c program.c $(CORE) $(HOST) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS -DPURE_ASYNC_EFFECTS test-async.c program.c $(HOST) $(CORE) $(LDFLAGS) -pthread $(LDLIBS) -o $@ + +$(BUILD)/test-splice: test-splice.c program.c $(CORE) $(HOST) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_RUNTIME_METRICS -DPURE_STORE_TEST test-splice.c program.c $(CORE) $(HOST) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-paged-store: test-paged-store.c store-paged.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-paged-store.c store-paged.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/core-layout: core-layout.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) core-layout.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/core-layout-async: core-layout.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_ASYNC_EFFECTS core-layout.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/measure-peak-rss: measure-peak-rss.c | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) measure-peak-rss.c $(LDFLAGS) $(LDLIBS) -o $@ + +# Run on the release build, separately from sanitizer-instrumented objects. +check-core: core $(BUILD)/core-layout + python3 check_core.py --archive $(BUILD)/libpure-execution.a --layout $(BUILD)/core-layout $(if $(CORE_EVIDENCE),--output "$(CORE_EVIDENCE)") + +check-async: async-core $(BUILD)/core-layout-async $(BUILD)/test-async + python3 check_core.py --archive $(BUILD)/libpure-execution-async.a --layout $(BUILD)/core-layout-async --budget async-core-budget.json $(if $(ASYNC_CORE_EVIDENCE),--output "$(ASYNC_CORE_EVIDENCE)") + $(BUILD)/test-async + +check-async-server: async-server fixed-editor $(BUILD)/pure-connections-async-gated + python3 check_profile.py --build $(BUILD) --budget async-server-budget.json $(if $(ASYNC_SERVER_PROFILE_EVIDENCE),--output "$(ASYNC_SERVER_PROFILE_EVIDENCE)") + python3 check_async_server.py --server $(BUILD)/pure-connections-async-gated --editor $(BUILD)/pure-edit-fixed $(if $(ASYNC_SERVER_EVIDENCE),--output "$(ASYNC_SERVER_EVIDENCE)") + +# Release-linked default hosts are reviewed separately from the execution core. +# Do not run this size gate on sanitizer-instrumented binaries. +check-profile: editor server + python3 check_profile.py --build $(BUILD) $(if $(PROFILE_EVIDENCE),--output "$(PROFILE_EVIDENCE)") + +check-fixed-profile: fixed-editor fixed-server $(BUILD)/measure-peak-rss + python3 check_profile.py --build $(BUILD) --budget fixed-budget.json $(if $(FIXED_PROFILE_EVIDENCE),--output "$(FIXED_PROFILE_EVIDENCE)") + python3 check_micro_memory.py --build $(BUILD) --fixed $(if $(FIXED_MEMORY_EVIDENCE),--output "$(FIXED_MEMORY_EVIDENCE)") + +check-fixed-server: fixed-editor fixed-server server + python3 check_fixed_server.py --build $(BUILD) $(if $(FIXED_SERVER_EVIDENCE),--output "$(FIXED_SERVER_EVIDENCE)") + +check-service-scale: fixed-editor fixed-server editor server + python3 check_service_scale.py --build $(BUILD) $(if $(SERVICE_SCALE_EVIDENCE),--output "$(SERVICE_SCALE_EVIDENCE)") + +check-async-service-scale: fixed-editor fixed-server editor server async-server + python3 check_service_scale.py --build $(BUILD) --async-source $(if $(SERVICE_SCALE_EVIDENCE),--output "$(SERVICE_SCALE_EVIDENCE)") + +check-tui-profile: fixed-tui + python3 check_profile.py --build $(BUILD) --budget tui-budget.json $(if $(TUI_PROFILE_EVIDENCE),--output "$(TUI_PROFILE_EVIDENCE)") + +check-source-tui-profile: source-tui + python3 check_profile.py --build $(BUILD) --budget source-tui-budget.json $(if $(TUI_PROFILE_EVIDENCE),--output "$(TUI_PROFILE_EVIDENCE)") + +check-tui: fixed-editor check-tui-profile + python3 check_tui.py --build $(BUILD) $(if $(TUI_EVIDENCE),--output "$(TUI_EVIDENCE)") + +check-source-tui: editor fixed-editor check-source-tui-profile + python3 check_tui.py --build $(BUILD) --source $(if $(TUI_EVIDENCE),--output "$(TUI_EVIDENCE)") + +check-release: check-core check-profile check-fixed-profile check check-policy check-micro-memory + +check-micro-memory: editor $(BUILD)/measure-peak-rss + python3 check_micro_memory.py --build $(BUILD) $(if $(MEMORY_EVIDENCE),--output "$(MEMORY_EVIDENCE)") + +check-hybrid: + $(MAKE) BUILD=build/hybrid STORE=store-paged.c check build/hybrid/test-paged-store + build/hybrid/test-paged-store + python3 check_profile.py --build build/hybrid --budget hybrid-budget.json + $(MAKE) BUILD=build/hybrid STORE=store-paged.c check-micro-memory + python3 check_small_scan.py --editor build/hybrid/pure-edit $(if $(SMALL_SCAN_EVIDENCE),--output "$(SMALL_SCAN_EVIDENCE)") + $(MAKE) check-flat-export + +# Selected page format can be exported to the unchanged default store format. +# This conversion tool and test are outside the runtime and default app builds. +check-flat-export: fixed-editor + $(MAKE) BUILD=build/hybrid STORE=store-paged.c fixed-editor + python3 check_flat_export.py --paged-editor build/hybrid/pure-edit-fixed --flat-editor $(BUILD)/pure-edit-fixed $(if $(EXPORT_EVIDENCE),--output "$(EXPORT_EVIDENCE)") + +# Real source document -> two same-process domains using pinned mapped input. +check-mirror: mirror fixed-editor $(BUILD)/measure-peak-rss + python3 check_mirror.py --mirror $(BUILD)/pure-mirror --editor $(BUILD)/pure-edit-fixed --rss-helper $(BUILD)/measure-peak-rss $(if $(MIRROR_SCRATCH_PARENT),--scratch-parent "$(MIRROR_SCRATCH_PARENT)") $(if $(MIRROR_EVIDENCE),--output "$(MIRROR_EVIDENCE)") + +# Selected Linux-only storage copy experiment; does not change the default build. +check-copy-range: editor $(BUILD)/measure-peak-rss + $(MAKE) BUILD=build/cfr CPPFLAGS="$(CPPFLAGS) -DPURE_STORE_CFR_COPY" check check-profile check-micro-memory check-service-scale + python3 check_copy_path.py --baseline $(BUILD)/pure-edit --candidate build/cfr/pure-edit --baseline-helper $(BUILD)/measure-peak-rss --candidate-helper build/cfr/measure-peak-rss $(if $(COPY_RANGE_EVIDENCE),--output "$(COPY_RANGE_EVIDENCE)") + +check-peer-profile: document-peer + python3 check_profile.py --build $(BUILD) --budget peer-budget.json $(if $(PEER_PROFILE_EVIDENCE),--output "$(PEER_PROFILE_EVIDENCE)") + +check-document-server-profile: document-server + python3 check_profile.py --build $(BUILD) --budget peer-server-budget.json $(if $(PEER_SERVER_PROFILE_EVIDENCE),--output "$(PEER_SERVER_PROFILE_EVIDENCE)") + +check-window-profile: window + python3 check_profile.py --build $(BUILD) --budget window-budget.json $(if $(WINDOW_PROFILE_EVIDENCE),--output "$(WINDOW_PROFILE_EVIDENCE)") + +check-document-peer: check-peer-profile editor + python3 check_document_peer.py --peer $(BUILD)/pure-document-peer --editor $(BUILD)/pure-edit $(if $(PEER_EVIDENCE),--output "$(PEER_EVIDENCE)") + +check-document-server: check-document-server-profile document-peer editor + python3 check_document_peer.py --peer $(BUILD)/pure-document-peer --server $(BUILD)/pure-document-server --editor $(BUILD)/pure-edit $(if $(PEER_SERVER_EVIDENCE),--output "$(PEER_SERVER_EVIDENCE)") + +# Opt-in TLS host checks: same authenticated work and failure scenarios without +# Linux /proc RSS or GNU binary-size tools. Requires OpenSSL headers and libs. +check-portable-peer: document-peer document-server editor + python3 check_document_peer.py --peer $(BUILD)/pure-document-peer --editor $(BUILD)/pure-edit --portable $(if $(PORTABLE_PEER_EVIDENCE),--output "$(PORTABLE_PEER_EVIDENCE)") + python3 check_document_peer.py --peer $(BUILD)/pure-document-peer --server $(BUILD)/pure-document-server --editor $(BUILD)/pure-edit --portable $(if $(PORTABLE_DOCUMENT_SERVER_EVIDENCE),--output "$(PORTABLE_DOCUMENT_SERVER_EVIDENCE)") + +check: $(BUILD)/test-flow $(BUILD)/test-program $(BUILD)/test-fields $(BUILD)/test-runtime $(BUILD)/test-splice $(BUILD)/pure-edit $(BUILD)/pure-connections + $(BUILD)/test-flow + $(BUILD)/test-program + $(BUILD)/test-fields + python3 check.py --server $(BUILD)/pure-connections --runtime-test $(BUILD)/test-runtime --splice-test $(BUILD)/test-splice --editor $(BUILD)/pure-edit --store-source $(STORE) $(if $(EVIDENCE),--output "$(EVIDENCE)") + +check-policy: editor server source-tui + $(MAKE) BUILD=$(BUILD)/policy FIXED_PROGRAM=examples/bounded-editor.pure fixed-editor fixed-server fixed-tui + python3 check_policy.py --source $(BUILD)/pure-edit --fixed $(BUILD)/policy/pure-edit-fixed --source-server $(BUILD)/pure-connections --fixed-server $(BUILD)/policy/pure-connections-fixed --source-tui $(BUILD)/pure-edit-tui --fixed-tui $(BUILD)/policy/pure-edit-tui-fixed --definition examples/bounded-editor.pure $(if $(POLICY_PEER),--peer $(POLICY_PEER)) $(if $(POLICY_EVIDENCE),--output "$(POLICY_EVIDENCE)") + +check-policy-peer: document-peer + $(MAKE) BUILD=$(BUILD) POLICY_PEER=$(BUILD)/pure-document-peer check-policy + +# Host-neutral C smoke path; the release size/RSS gates above are calibrated +# only for Linux AArch64 and must not be reported as macOS measurements. +check-portable: $(BUILD)/test-flow $(BUILD)/test-program $(BUILD)/test-fields $(BUILD)/test-runtime $(BUILD)/test-splice $(BUILD)/test-async $(BUILD)/pure-edit $(BUILD)/pure-connections $(BUILD)/pure-edit-tui-fixed $(BUILD)/pure-edit-tui + $(BUILD)/test-flow + $(BUILD)/test-program + $(BUILD)/test-fields + $(BUILD)/test-runtime + $(BUILD)/test-splice + $(BUILD)/test-async + +# Same real PTY/CLI scenarios as the Linux TUI gates, with no /proc RSS claim. +check-portable-tui: fixed-editor fixed-tui editor source-tui + python3 check_tui.py --build $(BUILD) --portable $(if $(PORTABLE_FIXED_TUI_EVIDENCE),--output "$(PORTABLE_FIXED_TUI_EVIDENCE)") + python3 check_tui.py --build $(BUILD) --source --portable $(if $(PORTABLE_SOURCE_TUI_EVIDENCE),--output "$(PORTABLE_SOURCE_TUI_EVIDENCE)") + +# Host-native behavior plus the same real document workload on both CLI variants. +# RSS is recorded on macOS and Linux; only check-micro-memory enforces the +# reviewed Linux AArch64 ceiling. +check-native-checkpoint: check-portable check-portable-tui fixed-editor $(BUILD)/measure-peak-rss + python3 check_micro_memory.py --build $(BUILD) --portable $(if $(PORTABLE_SOURCE_MEMORY_EVIDENCE),--output "$(PORTABLE_SOURCE_MEMORY_EVIDENCE)") + python3 check_micro_memory.py --build $(BUILD) --fixed --portable $(if $(PORTABLE_FIXED_MEMORY_EVIDENCE),--output "$(PORTABLE_FIXED_MEMORY_EVIDENCE)") diff --git a/prototypes/connections/README.md b/prototypes/connections/README.md new file mode 100644 index 0000000..31b9c27 --- /dev/null +++ b/prototypes/connections/README.md @@ -0,0 +1,545 @@ +# Pure: 작성한 연결을 준비해 실행하는 네이티브 경로 + +기존 Workbench에서 만든 코드·배치를 실제 TCP 요청으로 저장하고 새 실행기에서 복원한다. +서버는 C17 네이티브 실행 파일이다. SQL·SQLite 의존성을 제거했고, 호출자의 바이트를 +그대로 파일에 기록한다. 저장할 구간과 응답할 구간은 `.pure` 파일로 작성하며 서버 시작 때 +실행 경로를 준비한다. 기존 Node/Wasm CLI는 그대로 사용한다. + +이 구현은 저장·전송의 수명을 확인하는 최소 경로다. 객체별 파일이나 전체 교체 방식을 +Pure의 최종 데이터 모델로 정한 것은 아니다. 필드 배치·상수 구간·별칭은 준비 단계에서 +처리하고, 입력 값에 의존하는 구간은 요청 때 계산한다. +[구현 결과와 한계](../../docs/research/021-field-connections.md). + +## 실행 코어의 현재 상태 + +요청 접수·본문 예산·작업 준비·완료·해제를 `runtime.c`가 관리한다. HTTP는 그 코어의 한 +입력/출력 어댑터다. 다른 실행 영역에 결과를 전달할 때 코어가 원본을 유지하며, 수신 영역의 +효과와 응답 소비가 끝나면 회수한다. `test-runtime.c`는 수명·예산 단위 계약을, +선택형 `make check-mirror`는 실제 1 MiB 문서가 한 프로세스의 두 저장 영역으로 +전달되는 것을 검사한다. [실제 연결 비용과 한계](../../docs/research/063-real-in-process-routing.md). + +`make all`은 파서 없는 고정 정의 CLI와 실행 전용 `build/libpure-execution.a`를 만든다. +빌드 시 `.pure` 정의를 준비하는 컴파일러도 생성하지만 CLI에는 링크되지 않는다. +실행 중 소스를 읽는 CLI는 `make editor`, HTTP 호스트는 `make server`로 선택한다. +아카이브에는 `runtime.c`만 +들어가며 문서 평가기·파서·HTTP·POSIX 저장 구현은 없다. 문서 평가는 선택한 +`store-port.c`에 연결된다. 서버는 소스 파일을 읽으므로 파서를 별도로 포함한다. +기본 호스트 효과는 동기식이고 작성 문법은 아직 get/put 저장 프로필이다. +전체 앱 런타임·자체 보안 RPC·스레드 간 안전성·강제 메모리 격리를 구현한 것은 아니다. +[구현·비용·검증 보고서](../../docs/research/022-execution-core.md), +[첫 공개 체크포인트](../../CHECKPOINT.md). + +인증 문서 피어가 필요한 경우 `make document-peer`는 서버·클라이언트 명령을 +한 실행물에 넣는다. 서버만 배치할 때는 `make document-server`를 선택할 수 있다. +`make check-document-server`는 서버 전용 코드·의존성 상한과 기존 C/Python TLS +문서 시나리오를 검사한다. 이 선택 빌드는 기본 코어·CLI에 TLS를 추가하지 않는다. +루프백의 [직접 서버 비교와 전송 지연 결과](../../docs/research/055-authenticated-peer-transport-cost.md)는 +전체 프로세스 비용과 아직 남은 다중 연결 한계를 함께 기록한다. +이동 가능한 서버 전용 묶음은 `package_native.py --with-peer-server --output FILE`로 +만들고 `check_package.py FILE`로 검증한다. CLI+TLS 아카이브 전체 상한과 +[이동 후 인증·문서 작업 결과](../../docs/research/056-server-only-document-package.md)를 +별도로 공개한다. + +## 필요한 구성만 빌드하기 + +`make core`, `make fixed-editor`, `make editor`, `make server`로 구성을 각각 선택한다. `make core`에는 +소스 파서·저장소·네트워크·UI가 들어가지 않는다. 영역마다 스레드·소켓·힙을 생성하지 않는다. +release 빌드의 `make check-core`는 코어 코드 섹션 4,864 B, data/BSS 0 B와 현재 구조체 비용의 +상한·아카이브 의존성을 검사한다. 현재 Linux AArch64의 기본 구성은 +`text` 4,632 B / 영역 120 B / 작업 216 B다. 누적 계수가 필요한 HTTP 호스트와 +테스트는 `PURE_RUNTIME_METRICS`를 선택한다. 이 프로필의 영역은 192 B다. +코어에서 문서 해석을 분리하기 전의 `text` 5,980 B / 영역 128 B와 구분한다. +[현재 경계와 전체 앱 비용](../../docs/research/068-opaque-app-core.md)에 기록했다. +[해제 콜백 재진입 방어 비용](../../docs/research/064-release-callback-reentrancy.md)을 +기본 코어 상한 안에서 검증했다. +[실제 앱 1,000개 비교](../../docs/research/051-optional-runtime-metrics.md)에서 두 비용을 +분리했다. 전체 앱의 RAM 수치는 아니다. GNU 호환 도구를 사용하는 현재 프로필의 회귀 검사다. +`make check-profile`은 기본 CLI·HTTP 실행물의 링크된 섹션과 직접 동적 의존성을 검사한다. +두 검사를 실제 동작·CLI 전체 프로세스 RSS 검사와 함께 실행할 때는 release 빌드에서 +`make check-release`를 사용한다. RSS 검사는 1 B/1 MiB 실제 문서 편집·읽기를 +[현재 장치의 4 MiB 회귀 상한](memory-budget.json)과 비교한다. +이 검사에는 파서 없는 고정 정의 CLI·HTTP 실행물의 크기·RSS 상한도 포함된다. +`make check-service-scale`은 같은 문서 앱의 1,024개 객체를 8개 클라이언트가 +생성·읽기·수정·복원하여 두 HTTP 호스트의 결과, 서버 RSS, 버퍼 회수를 확인한다. +[검사 범위와 결과](../../docs/research/040-document-service-capacity.md). +`make check-async`는 선택형 비동기 완료 코어와 실제 저장 효과를 검사한다. +유휴 동기 앱은 `pure_runtime_bind()`로 작업 슬롯을 분리할 수 있다. 진행 중인 +작업·대여가 남으면 분리를 거절하며, 슬롯 재결합에도 티켓 세대는 유지된다. +[실제 네이티브 앱 1,000개의 메모리 비용](../../docs/research/049-idle-app-work-slots.md). +기본 코어·CLI·HTTP에는 작업자 스레드나 비동기 완료 코드가 링크되지 않는다. +선택형 코어 자체는 작업자를 만들지 않으며, 효과를 가져온 호스트가 I/O를 실행하고 +같은 소유 스레드에서 완료해야 한다. 기본 서비스 호스트는 이 경로를 사용하지 +않는다. [계약·비용·한계](../../docs/research/045-owner-thread-async-effects.md). +`make async-server`는 이 계약을 기존 문서 HTTP 앱에 선택적으로 연결한다. +`make check-async-server`는 크기 상한과 쓰기가 멈춘 동안 기존 문서 읽기·기한· +종료를 검사하고, `make check-async-service-scale`은 기존 1,024개 문서 작업을 +같은 소스 정의 앱으로 다시 실행한다. 이 선택 호스트는 읽기 작업자 하나와 +쓰기 작업자 하나를 사용하고, 모든 쓰기는 한 작업자에서 직렬화한다. +[현재 동작과 비용](../../docs/research/048-immutable-read-write-overlap.md). +GTK·TLS를 선택하지 않아도 기본 실행물에 섞이면 검사가 실패한다. 예산은 +[`profile-budget.json`](profile-budget.json)에 기록했으며, 호스트·컴파일러가 바뀌면 +측정값을 다시 검토해야 한다. 이 검사만으로 전체 앱 RAM이 작다고 증명하지는 않는다. +[현재 비용과 제한](../../docs/research/023-document-edits.md). + +Linux AArch64의 기본 시험 묶음에는 CLI와 `.pure` 정의만 넣는다. `make check-micro-memory`로 +빌드한 뒤 첫 명령을 실행한다. 전체 교차 검증 묶음은 `make all server document-peer window` +와 `make check-micro-memory` 뒤에 HTTP·피어·창을 모두 명시적으로 선택한다. +패키징은 CLI 메모리 상한도 다시 확인한다. 소스 작성 묶음은 디버그 정보를, +고정 정의 묶음은 실행에 필요 없는 심볼까지 제거하며 빌드 실행물은 그대로 둔다. +둘 다 정적 실행물이 아니고 매니페스트에 시스템 공유 +라이브러리를 기록한다. `check_package.py`는 묶음을 다른 위치에 풀어 선택된 +경로의 실제 동작을 다시 확인한다. +고정 정의 묶음은 선택한 CLI·HTTP·TUI 조합의 압축 아카이브 전체 크기 상한도 +[`fixed-package-budget.json`](fixed-package-budget.json)으로 검사한다. 초과하면 패키지를 +만들지 않는다. 이 값은 libc·터미널·저장 파일을 포함한 실행 메모리 값은 아니다. +[현재 터미널 편집기](../../docs/research/044-terminal-viewport-cost.md)는 여러 행·탭 문서의 +재열기를 처리하며, 1 MiB 화면의 반복 입력은 문서 전체를 매번 스캔하지 않는다. +[문서 이름 접두어 탐색](../../docs/research/050-bounded-document-prefix-find.md)은 이미 정렬된 +목록을 사용하며, 목록을 연 상태에서 이름을 입력하고 Enter로 연다. Backspace는 +접두어를 줄이고 화살표는 검색어를 지운 뒤 목록을 이동한다. + +```sh +python3 prototypes/connections/package_native.py \ + --output dist/pure-document-micro-linux-aarch64.tar.gz +python3 prototypes/connections/check_package.py \ + dist/pure-document-micro-linux-aarch64.tar.gz + +python3 prototypes/connections/package_native.py \ + --with-http --with-peer --with-window \ + --output dist/pure-document-authoring-linux-aarch64.tar.gz +python3 prototypes/connections/check_package.py \ + dist/pure-document-authoring-linux-aarch64.tar.gz +``` + +필요한 호스트만 선택할 수도 있다. 예를 들어 `--with-window`만 주면 HTTP·TLS 없이 +CLI와 GTK 창만 포함하며, 창의 외부 변경·충돌은 CLI를 통해 검증한다. + +[기본·선택 묶음, 메모리 비용과 한계](../../docs/research/032-micro-native-authoring-boundary.md). + +동일한 문서 인터페이스에 인라인/16 KiB 페이지 혼합 저장소를 **선택형 연구 경로**로 붙여볼 수 있다. +작은 기존 `PUREB001` 값은 그대로 읽지만, 큰 값을 `PUREP001`로 바꾼 뒤에는 기본 저장기로 +직접 열 수 없다. 따라서 실험에 별도 `--store` 디렉터리를 쓴다. 아래 내보내기 +명령은 새 기본 형식 디렉터리를 만들어 본문·리비전을 보존하며, 기존 디렉터리를 +제자리에서 바꾸지 않는다. 기본 저장소를 교체하는 명령이 아니다. + +```sh +make -C prototypes/connections check-hybrid +printf 'hello Pure\n' > /tmp/pure-hybrid-insert.txt +prototypes/connections/build/hybrid/pure-edit \ + --program prototypes/connections/examples/editor.pure \ + --store /tmp/pure-hybrid-documents edit note 0 0 0 /tmp/pure-hybrid-insert.txt +prototypes/connections/build/hybrid/pure-edit \ + --program prototypes/connections/examples/editor.pure \ + --store /tmp/pure-hybrid-documents show note +python3 prototypes/connections/package_native.py \ + --build prototypes/connections/build/hybrid --hybrid-storage \ + --output dist/pure-document-hybrid-micro-linux-aarch64.tar.gz +make -C prototypes/connections check-flat-export +python3 prototypes/connections/export_flat_store.py \ + --source /tmp/pure-hybrid-documents --destination /tmp/pure-flat-documents \ + --paged-editor prototypes/connections/build/hybrid/pure-edit-fixed \ + --flat-editor prototypes/connections/build/pure-edit-fixed +``` + +[실제 큰·작은 문서 쓰기량과 전체 제한](../../docs/research/034-hybrid-document-storage.md), +[기본 형식으로 내보내는 검증](../../docs/research/062-paged-store-rollback.md). + +같은 `.pure` 문서 편집기를 선택형 페이지 저장소와 함께 터미널에서 사용하려면 +Linux의 16 KiB OS 페이지 환경에서 새 저장 디렉터리를 지정한다. CLI와 TUI를 +함께 담은 [선택형 묶음](results/2026-09-24-native-save/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz)은 +다음 빌드로 만들며, 기본 `make all`과 기본 저장 형식은 유지된다. + +```sh +make -C prototypes/connections BUILD=build/hybrid STORE=store-paged.c editor source-tui +python3 prototypes/connections/package_native.py \ + --build prototypes/connections/build/hybrid --hybrid-storage --with-tui \ + --output dist/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz +python3 prototypes/connections/check_package.py \ + dist/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz +``` + +[실제 Ctrl+S 완료 시간·코드·RSS 비용](../../docs/research/065-native-save-latency.md)을 +함께 검토해야 한다. 큰 문서의 `PUREP001` 형식은 기본 저장기가 직접 열 수 없다. + +기본 `PUREB001` 형식을 유지하며 큰 수정의 사용자 공간 복사 호출을 줄이는 +Linux 전용 선택형 실험은 `make -C prototypes/connections check-copy-range`로 +검사한다. `strace`·`findmnt`와 GNU 크기 도구가 필요하며, 기본 `make editor`에는 +이 경로가 들어가지 않는다. [같은 1 MiB 문서의 시스템 호출·전체 RSS·코드 비용과 +지원 범위](../../docs/research/059-immutable-file-copy-path.md)를 분리해 공개한다. + +## 네이티브 CLI로 문서 일부 바꾸기 + +저장소 루트에서 실행한다. 입력 파일에 삽입할 실제 내용만 둔다. + +```sh +make -C prototypes/connections editor +printf 'hello Pure\n' > /tmp/pure-insert.txt +prototypes/connections/build/pure-edit --program prototypes/connections/examples/editor.pure --store prototypes/connections/build/edited-objects edit note 0 0 0 /tmp/pure-insert.txt +printf 'world' > /tmp/pure-insert.txt +prototypes/connections/build/pure-edit --program prototypes/connections/examples/editor.pure --store prototypes/connections/build/edited-objects edit note 1 6 4 /tmp/pure-insert.txt +prototypes/connections/build/pure-edit --program prototypes/connections/examples/editor.pure --store prototypes/connections/build/edited-objects show note +``` + +새 저장소에서 마지막 출력은 `hello world`와 줄바꿈이다. `edit`의 숫자는 예상 버전·시작 바이트· +삭제 바이트 수다. 성공 시 JSON 버전을 출력하며, `show`는 stdout에 원문을, stderr에 버전을 +출력한다. 버전 충돌의 종료 코드는 3, 다른 오류는 2다. 변경이 없더라도 성공한 효과는 버전을 +증가시킨다. UTF-8 문자소 위치는 호출자가 바이트 범위로 변환해야 한다. + +편의 명령은 준비된 두 숫자 필드와 뒤따르는 삽입 바이트를 채운다. 다른 입력 구성은 +`apply KEY REVISION INPUT_FILE`로 실행한다. `--budget BYTES`를 명령 앞에 지정하면 입력/결과의 +본문 크레딧을 제한한다. 저장소가 파일을 다시 쓰는 4 KiB 스택 버퍼·커널 비용은 별도다. +서버에 같은 `editor.pure`·저장 경로를 지정하면 외부 HTTP 입력도 같은 효과를 실행한다. +이 CLI는 네이티브 자동화 경로다. 문서 창은 아래의 선택 빌드다. + +## 인증된 문서 피어 + +`make document-peer`는 같은 `editor.pure`·저장소·실행 코어를 사용하면서 TLS 1.3 +상호 인증과 `pure-doc/1` 자체 이진 형식을 선택 빌드한다. 기본 `make all`에는 +OpenSSL이 들어가지 않는다. 준비된 CA와 서버·클라이언트 인증서가 필요하며, +서버 인증서의 DNS SAN은 `localhost`, 클라이언트 인증서의 DNS SAN은 +아래 예시의 `pure-client`와 일치해야 한다. + +```sh +make -C prototypes/connections document-peer +prototypes/connections/build/pure-document-peer serve 127.0.0.1 9443 \ + prototypes/connections/examples/editor.pure /tmp/pure-documents \ + /path/to/ca.pem /path/to/server.pem /path/to/server.key pure-client +prototypes/connections/build/pure-document-peer call 127.0.0.1 9443 localhost \ + prototypes/connections/examples/editor.pure /path/to/ca.pem \ + /path/to/client.pem /path/to/client.key show note +``` + +`call`은 `show KEY`, `edit KEY REVISION OFFSET REMOVE INSERT_FILE`, +`apply KEY REVISION INPUT_FILE`을 받는다. 읽기는 원문을 stdout에 보낸다. +편집은 결과 본문을 소비하지 않으므로 논리적 결과 길이만 받고 본문 전송은 생략한다. +서버는 지속 세션을 처리하지만 예제 C 명령은 호출마다 연결한다. 인증서를 자동 생성해 +실제 C·Python 클라이언트, 1 MiB 문서, 실패·재시작을 검사하려면 +`make -C prototypes/connections check-document-peer`를 실행한다. 이 대상은 선택형 +실행물의 코드·직접 의존성 [예산](peer-budget.json)도 검사한다. +[형식·비용·한계](../../docs/research/030-authenticated-document-peer.md). + +## 네이티브 문서 창 + +Linux에서 GTK 4.10 이상 개발 헤더·라이브러리와 `pkg-config`가 있으면 같은 +[`editor.pure`](examples/editor.pure)를 실제 텍스트 창으로 실행할 수 있다. GTK는 이 +선택 빌드에만 들어가며 `make all`이나 코어·CLI·서버의 의존성이 아니다. + +```sh +make -C prototypes/connections window +prototypes/connections/build/pure-edit-window \ + --program prototypes/connections/examples/editor.pure \ + --store /tmp/pure-documents note +``` + +문서를 편집한 뒤 **Save** 또는 Ctrl+S를 누른다. **Definition** 버튼 또는 +Ctrl+Shift+D로 같은 `.pure` 소스를 편집하고 **Apply & save** 또는 Ctrl+Shift+S로 +현재 창에 적용한다. 잘못된 정의, 다른 실행기의 소스 변경, 저장 실패는 초안과 기존 +실행 계획을 보존한다. 다른 실행기는 재시작한 뒤 저장된 정의를 읽는다. +창 아래의 문서 키 입력과 **Open** 또는 Ctrl+O로 다른 문서를 연다. 없는 키는 새 +빈 문서로 열고, 미저장 문서·정의 초안이 있거나 키·본문이 유효하지 않으면 +현재 문서와 버전을 유지한다. 목록·검색 기능은 아직 없다. +변경한 바이트 구간만 작성된 splice 입력에 +넣고, 저장소는 이전 버전이 일치할 때만 공개한다. CLI·HTTP 서버도 같은 프로그램과 저장 +경로를 쓰므로 다른 실행기가 먼저 저장하면 창은 초안을 유지하며 충돌을 보여준다. Reload는 +미저장 변경을 보호하고 Discard & reload만 명시적으로 버린다. 닫기·Ctrl+W도 미저장 +초안을 확인한다. 창은 UTF-8 텍스트만 열며 이진 데이터는 CLI로 다룬다. + +실제 창·CLI·HTTP 교차 검증, 정의 적용·실패 복구, 1 MiB 문서 수정과 전체 메모리 비용은 +[032 보고서](../../docs/research/032-micro-native-authoring-boundary.md)에 있다. 화면 없이 +같은 시나리오를 재현하려면 Xvfb·xdotool·xclip을 설치하고 다음을 실행한다. + +```sh +make -C prototypes/connections window editor server +xvfb-run -a -s '-screen 0 1280x800x24' \ + python3 prototypes/connections/check_window.py +``` + +## 작은 네이티브 터미널 편집기 + +빌드 시 준비한 같은 문서 앱을 GTK 없이 터미널에서 편집하려면 `fixed-tui`를 +선택한다. Ctrl+S 저장, Ctrl+O 다른 키 열기, Ctrl+R 다시 읽기, Ctrl+Q 종료다. +미저장 초안을 버리려면 다시 읽기·종료 명령을 두 번 입력해야 한다. 이 실행물은 +`.pure` 정의를 실행 중 다시 작성하지 않는다. + +```sh +make -C prototypes/connections fixed-tui +prototypes/connections/build/pure-edit-tui-fixed --store /tmp/pure-documents note +make -C prototypes/connections check-tui +python3 prototypes/connections/package_native.py --fixed --with-tui \ + --output dist/pure-document-cli-tui-linux-aarch64.tar.gz +``` + +실제 PTY 조작·1 MiB 편집·프로세스 메모리·패키지의 범위는 +[042 보고서](../../docs/research/042-micro-native-terminal-editor.md)에 있다. + +실행 시 `.pure` 정의를 읽는 TUI가 필요하면 `source-tui`를 선택한다. 기존 고정 TUI와 +달리 파서가 선택한 실행물에 포함된다. 정의를 고친 뒤 새 세션을 열어야 적용되며, +편집 중인 문서 상태를 묵시적으로 변환하지 않는다. 기본 코어와 고정 TUI에는 영향이 없다. + +```sh +make -C prototypes/connections source-tui +prototypes/connections/build/pure-edit-tui --program \ + prototypes/connections/examples/editor.pure --store /tmp/pure-documents note +make -C prototypes/connections check-source-tui +python3 prototypes/connections/package_native.py --with-tui \ + --output dist/pure-document-source-cli-tui-linux-aarch64.tar.gz +``` + +구체적인 지원 범위와 전체 비용은 [소스 TUI 검증](../../docs/research/053-source-loaded-native-terminal-editor.md)에 기록한다. + +## 실행 + +POSIX 환경의 C17 컴파일러와 make가 필요하다. 검증 환경은 Linux AArch64다. +저장소 루트에서 실행한다. + +```sh +make -C prototypes/connections server +prototypes/connections/build/pure-connections --program prototypes/connections/examples/storage.pure --store prototypes/connections/build/objects --port 8767 +``` + +`--store`는 디렉터리다. 기존 SQLite 파일 등 일반 파일을 지정하면 거절하며 덮어쓰거나 +변환하지 않는다. 자동 마이그레이션은 제공하지 않는다. +`--program`은 필수다. 파일 수정은 다음 서버 시작에 반영되며 실행 중 교체는 지원하지 않는다. + +다른 터미널에서 기존 작업물을 연다. Node가 필요하다. + +```sh +node prototypes/workbench/cli.mjs prototypes/workbench/examples/direct.pure +``` + +CLI에서 실행할 예: + +```text +point drag down 90 170 +point drag move 360 220 +point drag up 360 220 +connect http://127.0.0.1:8767 my_work +push 0 +quit +``` + +빈 CLI를 다시 실행하고 `connect http://127.0.0.1:8767 my_work`, `pull`, `show`를 입력하면 +저장한 배치가 복원된다. 이후 변경은 `push`로 저장한다. `push 0`은 새 이름을 만들 때만 사용한다. +서버를 재시작해도 같은 저장 디렉터리를 지정하면 내용을 읽을 수 있다. 다른 실행기가 먼저 +갱신했다면 412 충돌로 거절하고 로컬 배치를 유지한다. `pull`은 로컬 배치를 원격 내용으로 +교체하므로, 보존할 작업은 먼저 `save-project PATH`로 저장한다. + +## 필드로 문서 선택·저장하기 + +[selection.pure](examples/selection.pure)는 선택 시작·길이와 문서를 입력받아 선택한 내용만 +저장하고 응답한다. 필드의 바이트 위치를 연산마다 직접 계산하지 않는다. + +```text +layout Selection { + start: u32le; + count: u32le; + document: bytes; +} + +on put { + let input = view(Selection, request.body); + let selected = range(input.document, input.start, input.count); + let saved = store.write(request.key, request.revision, selected); + reply(saved, selected); +} +``` + +예제 파일에는 저장한 내용을 조회하는 get도 있다. 별도 저장 디렉터리로 실행한다. + +```sh +prototypes/connections/build/pure-connections --program prototypes/connections/examples/selection.pure --store prototypes/connections/build/selections --port 8768 +``` + +다른 터미널에서 UTF-8 텍스트 파일의 선택 구간을 저장한다. `--start`·`--end`는 0부터 시작하는 +유니코드 코드 포인트 위치이며 끝은 포함하지 않는다. 바이트 위치 변환과 입력 인코딩은 +예제 Python 클라이언트가 맡는다. 사용자에게 보이는 글자 덩어리(grapheme) 단위는 아니다. + +```sh +python3 prototypes/connections/examples/select-text.py /path/to/document.txt --key excerpt --start 0 --end 5 --url http://127.0.0.1:8768 +``` + +성공하면 저장 버전과 선택 내용이 출력된다. 이후 갱신에는 `--revision 1`처럼 현재 버전을 +지정한다. 서버의 숫자 필드는 바이트 단위다. 예제 클라이언트는 UTF-8 문자 경계에 맞추지만 +서버의 `bytes` 필드 자체가 텍스트 인코딩을 검증하는 것은 아니다. 클라이언트는 Python이며 +필드 해석·범위 계산·저장·응답을 하는 서버는 네이티브다. + +`layout`은 선택한 입출력 경계의 배치 선언이다. 패딩 없이 선언 순서로 필드를 배치하며, +`u32le`, `bytes[N]`, 마지막 가변 `bytes`를 지원한다. 고정 필드만 있는 선언은 접두부를 읽는다. +다른 입력·저장·RAM 표현을 이 형식으로 바꾸도록 강제하지 않는다. 송신자와 수신자의 선언이 +맞아야 하며 자동 형식 감지·버전 변환은 없다. 예제는 두 u32 뒤에 원본 UTF-8 바이트를 보낸다. + +필드 이름과 타입은 준비 단계에서 접근 위치로 바뀐다. 요청별 이름 검색이나 레코드 객체 +할당은 없다. 이 예제에는 요청 시 계산할 범위가 하나 남고 저장·응답이 그 결과를 함께 참조한다. +입력 헤더가 잘렸거나 범위가 틀리면 저장 전에 실패한다. +[필드 연결 계약](../../docs/design/005-field-connections.md). + +## 상수 구간 작성과 준비 결과 확인 + +[storage.pure](examples/storage.pure)는 전체 작업물을 저장·복원하는 기존 동작이다. +[excerpt.pure](examples/excerpt.pure)는 저장된 본문의 일부만 응답한다. 다음 get 정의에서 +원본은 최소 80 B여야 하며 실제 응답은 오프셋 32부터 16 B다. + +```text +on get { + let object = store.read(request.key); + let window = range(object.body, 16, 64); + let excerpt = range(window, 16, 16); + reply(object, excerpt); +} +``` + +준비 결과는 저장소·소켓을 만들지 않고 확인할 수 있다. 서버의 `--program`에 같은 파일을 +지정하면 해당 경로로 실행한다. 부분 응답 예제는 전체 JSON을 요구하는 Workbench `pull`용이 아니다. + +```sh +prototypes/connections/build/pure-connections --check-program prototypes/connections/examples/excerpt.pure +``` + +`on put`에서는 저장할 부분을 고르고 같은 입력을 응답에도 연결할 수 있다. +해제·복사 코드를 따로 쓰지 않아도 원본 수신 버퍼를 전송 완료까지 유지한다. + +```text +on put { + let window = range(request.body, 2, 10); + let value = range(window, 3, 4); + let saved = store.write(request.key, request.revision, value); + reply(saved, value); +} +``` + +두 핸들러가 모두 필요하고 각각 저장 효과는 한 번이다. `range`는 정확한 범위를 검사한다. +사용하지 않은 구간의 실패도 보존하며, 쓰기의 모든 구간 정의는 저장 호출보다 앞에 둔다. +작성 오류는 저장소 초기화 전에 거절한다. 소스는 16 KiB, 핸들러당 정의는 32개까지다. +배치 선언은 4개, 선언당 필드는 16개, 입력 값에 의존하는 구간 계산은 8단계까지다. +이 동적 범위는 PUT 입력에 한정하며 GET의 숫자 필드 값으로 결정하는 범위는 시작 때 거절한다. +GET의 이름 기반 고정 필드·가변 꼬리 구간은 지원한다. 숫자/화면 DSL과 파서는 아직 별개다. +조건 분기·반복·여러 외부 효과·일반 산술은 이번 프로파일에 없다. +[작성 계약](../../docs/design/004-prepared-connections.md). + +실제 문서 효과에는 선택형 최종 크기 정책을 쓸 수 있다. +[`bounded-editor.pure`](examples/bounded-editor.pure)의 +`store.splice(request.key, request.revision, edit.start, edit.remove, edit.insert, 8)`은 +수정 후 문서가 8 B를 넘으면 `limit`을 돌려준다. 현재 크기·제거 범위·삽입 길이는 +쓰기 잠금 안에서 같은 버전으로 계산한다. 상한은 작성자가 준비한 상수이며 요청 본문이 +정책을 바꿀 수 없다. 기본 `editor.pure`는 기존 객체 상한을 사용한다. +`make check-policy`는 소스·고정 CLI/HTTP/TUI의 허용·거절·리비전·초안 보존을, +`make check-policy-peer`는 선택형 인증 피어까지 같은 정의로 검사한다. +이 기능은 문서 어댑터와 저장소에만 있고 4,632 B 기본 코어와 작업 슬롯은 늘리지 않는다. +일반 숫자 규칙 언어가 완성됐다는 뜻은 아니다. + +`--check-program`의 `steps`가 비면 구간 연산은 준비 단계에서 끝난다. `write_step`·`reply_step`이 +0이면 정적인 범위 설명을 쓰고, 양수이면 `steps`의 해당 1-based 결과를 사용한다. +`to_end`는 offset부터 원본 끝까지를 뜻한다. `runtime_view_steps`는 준비된 동적 단계 수이며 +실행 속도나 CPU 명령 수 측정값이 아니다. 오류가 나면 뒤 단계와 저장 효과는 실행하지 않는다. + +## 연결과 소유권 + +| 경로 | 데이터와 완료의 의미 | +|---|---| +| 수신 → 저장 | 본문 크기만큼 한도를 예약하고 수신한다. 작성한 구간을 쓰기 호출에 전달한다. 응답도 입력을 사용하면 전송 종료까지 원본을 유지하며, 아니면 쓰기 호출 종료 후 반납한다. | +| 저장 → 공개 | 쓰기 잠금 안에서 예상 revision을 확인한다. 새 파일에 메타데이터와 본문을 기록·fsync하고 rename으로 공개한다. | +| 공개 → 응답 | 디렉터리 fsync까지 성공해야 저장 성공으로 응답한다. 공개 후 fsync 실패는 `durability_unknown`이며 롤백으로 표현하지 않는다. | +| 조회 → 전송 | 중간 구간들의 길이 조건과 최종 범위를 같은 파일 버전에서 검사한다. 응답에 필요한 부분만 읽기 전용으로 매핑한다. 별도의 응답 본문 배열을 만들지 않는다. | +| 부분 전송 → 회수 | 헤더·본문의 전송 위치만 전진시킨다. 마지막 바이트를 OS에 넘겼거나 전송이 중단되면 매핑을 해제한다. 상대 앱의 수신 확인은 별개다. | +| 복원 → 상태 공개 | 클라이언트가 UTF-8·기존 작업물 형식·상태를 검증한 뒤 복원한다. 실패하면 로컬 상태와 기억한 원격 revision을 유지한다. | + +저장된 버전은 제자리에서 수정하지 않는다. 새 파일로 교체하므로 기존 매핑은 전송이 끝날 +때까지 이전 버전을 참조한다. 이는 불변 파일 운용 규칙에 의한 보장이다. 외부 프로그램이 +파일을 직접 수정·잘라내는 경우까지 `mmap`이 스냅샷을 보장하는 것은 아니다. + +저장 C API의 `pure_store_select`는 준비된 구간과 최소 원본 길이를 받아 필요한 부분만 매핑한다. +기본 `storage.pure`와 Workbench 복원은 전체 작업물을 사용한다. HTTP Range나 저장 파일의 +제자리 부분 갱신은 구현한 것이 아니다. 본문 없는 응답도 존재·파일 형식 검사는 수행한다. + +파일은 식별자·버전·길이의 24 B 헤더 뒤에 원본 본문을 둔다. 테이블·행·쿼리 계획은 없다. +이 헤더는 공개·복원을 위한 프로토타입 메타데이터이며 새 범용 문서·미디어 형식이 아니다. +Workbench JSON의 의미 검증은 클라이언트가 맡는다. 이 JSON이나 파일 배치를 Pure의 RAM +표현으로 강제하지 않는다. 현재 작성 가능한 연결은 등록된 읽기·쓰기·응답에 한정되며 +[모듈 계약 초안](../../docs/design/003-module-contract.md) 전체의 구현은 아니다. + +`GET /objects/NAME`, `PUT /objects/NAME`의 제한된 HTTP/1.1 요청 경로다. +이름은 영문·숫자·`_`·`-` 1~64자다. 생성은 `If-None-Match: *`, 갱신은 `If-Match: "revision"`을 +요구한다. 성공한 조회·저장은 `ETag: "revision"`을 반환한다. revision은 64비트 양수이며 +클라이언트에서도 문자열로 유지한다. 404 없음, 412 충돌, 413 크기/버전 한도, +503 예산 부족/쓰기 잠금, 500 저장 오류를 구분한다. 500의 `X-Pure-Error: durability_unknown`은 +새 내용이 이미 공개됐지만 영속화 확인이 실패했다는 뜻이다. 재조회는 현재 내용을 확인할 +수 있을 뿐 전원 장애 내구성을 증명하지 않는다. 응답 유실도 저장 취소를 뜻하지 않는다. +요청마다 연결을 닫고 Content-Length만 지원한다. 범용 HTTP/RPC 서버는 아니다. + +HTTP를 Pure 간 기본 통신 모델로 확정한 것은 아니다. +[자체 통신 형식 설계](../../docs/design/006-native-protocol.md)는 작성된 연결에서 바이너리 +형식을 준비하고 보안 전송을 별도로 선택하는 방향이다. 같은 get/put 효과를 실행하는 +[선택형 인증 문서 피어](../../docs/research/030-authenticated-document-peer.md)가 별도로 있다. +이 평문·요청당 연결 종료 HTTP 경로로 QUIC과 성능을 비교하지 않는다. + +## 한도와 남은 비용 + +- 이 절의 HTTP 서버는 `127.0.0.1`에만 바인드하며 인증·TLS가 없다. 인증된 선택형 + 문서 피어는 별도 실행물이고 인터넷 공개 배포는 검증하지 않았다. +- 동시 연결은 8개, 헤더는 연결당 4 KiB, 객체는 Workbench 파일 상한인 1,449,984 B까지다. +- 현재 준비 구조체는 검증 환경에서 프로그램당 648 B다. 동적 구간 8개를 위한 공간도 + 고정 예약한다. 이름·필드 표는 준비 후 유지하지 않지만 전체 컴파일·실행 메모리의 상한을 뜻하지 않는다. +- `--budget` 기본값은 4 MiB다. 수신 본문 할당과 전송 중 참조하는 본문 길이를 합해 접수한다. + 이는 전체 RSS·매핑 페이지·페이지 캐시·디스크 사용량의 상한이 아니다. 매핑 정렬·페이지 + 단위 비용, OS 소켓 버퍼, 클라이언트 JSON·문자열·응답 버퍼도 별도다. + 입력의 일부만 응답해도 원본 수신 할당 전체를 붙잡으므로 해당 예산을 전부 유지한다. +- 헤더와 함께 받은 본문 접두부는 한 번 복사하며 `ingress_prefix_copy_bytes`에 기록한다. + `borrowed_output_bytes`는 참조한 구간의 누적 길이다. 절약한 RAM의 측정값이 아니다. + 커널의 파일 쓰기·소켓 전송, 페이지 폴트, 클라이언트의 직렬화 비용은 남는다. +- `--timeout-ms` 기본값은 접속부터 10초다. 느린 송수신자는 기한 뒤 종료한다. 파일 작업은 + 동기 실행하므로 디스크 호출 중에는 기한을 강제로 집행하거나 취소하지 못한다. +- 매핑을 유지하면 이전 파일의 저장 공간도 남을 수 있다. 작은 범위 참조도 해당 파일 버전의 + 디스크 수명을 연장한다. 본문 예산만으로 장기 운용의 디스크 사용량을 보장하지 않는다. +- 쓰기는 디렉터리별 잠금 하나로 직렬화한다. C API 제어 호출은 단일 스레드를 전제로 하며, + 이 잠금 규칙을 따르는 여러 프로세스의 충돌을 처리한다. 다중 객체 트랜잭션은 없다. +- 공개 전 프로세스가 종료되면 `.pending-*` 파일이 남을 수 있다. 읽기는 이를 무시하며 + 자동 정리·디스크 복구 정책은 아직 없다. 본문 손상 검출용 체크섬도 없다. +- macOS 실행, 실제 전원 장애·디스크 고장은 검증하지 않았다. 파일을 이 어댑터 밖에서 + 제자리 수정하지 않는 로컬 저장소를 전제로 한다. + +## 검증 + +Python 3와 Node가 필요하다. + +```sh +make -C prototypes/connections check +make -C prototypes/connections check-portable +make -C prototypes/connections check-portable-tui +make -C prototypes/connections BUILD=build/native-checkpoint check-native-checkpoint +make -C prototypes/connections check-portable-peer +make -C prototypes/connections check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' +``` + +`check-portable`은 C 단위 동작을 검사하며 Linux AArch64의 GNU `size`/`readelf`와 +RSS 상한 검사를 요구하지 않는다. `check-portable-tui`는 고정·소스 정의 편집기를 +실제 PTY에서 검사하며 `/proc` RSS를 읽지 않는다. `check-native-checkpoint`는 +둘을 실행하고 고정·소스 CLI의 실제 1 MiB 생성·읽기·1 B 수정 RSS를 호스트 +단위에 맞춰 기록한다. [기록된 Linux 결과](../../docs/research/060-host-native-checkpoint.md)는 +코어 코드 증가 없이 기존 RSS 상한 검사를 통과했다. 현재 통과 결과는 +Linux AArch64에만 해당한다. 기존 `check-tui`·`check-source-tui`의 Linux +4,096 KiB RSS 상한 검사는 그대로 유지한다. Apple Silicon 실행과 +디렉터리 동기화·전원 장애 내구성은 별도 검증이 필요하다. +`check-portable-peer`는 OpenSSL이 준비된 경우에만 선택해서 실행한다. C 피어 +클라이언트·두 서버를 실제 TLS 소켓으로 검증하지만 `/proc` RSS는 수집하지 않는다. +[휴대형 피어 검사와 Linux 비용](../../docs/research/058-portable-authenticated-peer-check.md)을 +별도로 기록했으며 macOS에서의 통과 결과는 아직 없다. + +임시 디렉터리와 실제 루프백 TCP 소켓을 사용한다. 단위 검사에는 범위 접근·페이지 경계· +이전 참조 유지·공개 전후 실패 주입·공개 전 자식 프로세스 종료가 포함된다. 주입 훅은 +단위 검사 실행 파일에만 들어간다. 실제 두 서버의 쓰기 경쟁과 Workbench 저장·복원 등 +작성에 따른 부분 읽기·쓰기·응답, 한글·이모지 문서 선택, 필드 재배치, 잘린 입력·동적 범위· +수명 보존을 포함한 28개 통합 시나리오를 일반 빌드와 ASan·UBSan 빌드에서 확인했다. +준비된 구간과 순차 범위 연산, 숫자 필드의 정렬·바이트 순서·경계 동작도 대조했다. + +[검증 자료](results/2026-09-23-field-connections/README.md). 시간·처리량 비교는 수행하지 않았다. +상수 구간 작성 자료는 [020](results/2026-09-23-prepared-connections/README.md)에 있다. +작성 기능 도입 전 자료는 [019](results/2026-09-23-byte-storage/README.md)에 있다. +이전 SQLite 구현의 결과는 [018 자료](results/2026-09-23-native-connections/README.md)에 +별도로 보존했다. + +## 이번 변경의 판단 기준 + +이름 기반 필드·데이터 의존 범위를 실제 저장·응답으로 연결하는 +[착수 기준](../../docs/design/005-field-connections.md)을 확인했다. 다음에는 앱의 읽기·변경·저장 +경로에서 필요한 연산과 버전 관계를 다룬다. 범용 그래프 실행기나 자동 재시도·피드백 루프를 +검증 없이 추가하지 않는다. diff --git a/prototypes/connections/SOURCE_PREVIEW.md b/prototypes/connections/SOURCE_PREVIEW.md new file mode 100644 index 0000000..9e0d78d --- /dev/null +++ b/prototypes/connections/SOURCE_PREVIEW.md @@ -0,0 +1,119 @@ +# Pure native source preview + +This bundle contains the C17 execution core, the authored document app, its +CLI and optional terminal/HTTP/TLS hosts, plus the Workbench sources used by +the integration checks. It excludes prior benchmark results and build outputs. +`manifest.json` records the exact files and their SHA-256 hashes. + +Build and run the small default path from the extracted directory: + +```sh +make -C prototypes/connections check-portable +make -C prototypes/connections check-portable-tui +make -C prototypes/connections check-policy +make -C prototypes/connections fixed-editor fixed-tui +make -C prototypes/connections source-tui +mkdir -p /tmp/pure-source-store +printf 'hello Pure\n' > /tmp/pure-source-insert.txt +prototypes/connections/build/pure-edit-fixed --store /tmp/pure-source-store \ + edit note 0 0 0 /tmp/pure-source-insert.txt +prototypes/connections/build/pure-edit-fixed --store /tmp/pure-source-store show note +prototypes/connections/build/pure-edit-tui --program \ + prototypes/connections/examples/editor.pure --store /tmp/pure-source-store note +``` + +`check-portable` builds the C hosts and runs the host-neutral C behavior tests. +`check-portable-tui` runs the fixed and source-loaded terminal editors through +real PTYs, including UTF-8 input, resize, 1 MiB edits, conflicts and document +browsing. It records no Linux `/proc` RSS number; on macOS it is a behavior +check, not a proof of the 4 MiB Linux memory ceiling. +For a single native checkpoint command, run from the extracted directory: + +```sh +make -C prototypes/connections BUILD=build/native-checkpoint check-native-checkpoint +``` + +This requires a C17 compiler, Make and Python 3. It builds the core, CLI and +TUI, exercises both terminal variants, then records whole-process peak RSS for +the fixed and source-loaded CLIs during a real 1 MiB document creation, read +and 1-byte revision edit. To save the two memory results, add +`PORTABLE_SOURCE_MEMORY_EVIDENCE=/path/source.json` and +`PORTABLE_FIXED_MEMORY_EVIDENCE=/path/fixed.json` to the `make` command. +The RSS helper reports the OS raw unit and normalized KiB. No macOS RSS budget +has been established; compare macOS results only with the same host and build. +The optional `make -C prototypes/connections check-portable-peer` builds the +TLS client and two server profiles, then checks mutual authentication, document +editing, invalid frames and restart over real sockets. It requires OpenSSL +headers, libraries and command-line tool, and records no RSS in portable mode. +On Linux AArch64, `make -C prototypes/connections check-release` additionally +checks the measured code, dependency and process-memory ceilings and the actual +document/HTTP paths; it needs Python 3, Node.js, and GNU `size`/`readelf`. +`check-policy` runs the authored final-size guard through the CLI, HTTP and +terminal hosts; `check-policy-peer` additionally requires OpenSSL and checks +the same policy over mutually authenticated TLS. +The source-loading CLI can run a changed `.pure` definition with +`make -C prototypes/connections editor` and `--program PATH`. + +The selected 16 KiB-page document store can be exported into a **new** default +store directory without linking it into the small default app: + +```sh +make -C prototypes/connections fixed-editor +make -C prototypes/connections BUILD=build/hybrid STORE=store-paged.c fixed-editor +python3 prototypes/connections/export_flat_store.py \ + --source /path/to/paged-store --destination /path/to/new-flat-store \ + --paged-editor prototypes/connections/build/hybrid/pure-edit-fixed \ + --flat-editor prototypes/connections/build/pure-edit-fixed +``` + +The source stays intact. The tool holds its writer lock during conversion and +checks every object with the default editor before publishing the destination. +Writers may receive `busy` until it finishes; schedule this as maintenance. +The destination must not exist. `make -C prototypes/connections check-flat-export` +exercises the conversion with real mixed-size and repeatedly edited documents. + +On a Linux host with 16 KiB OS pages, the selected hybrid store can also run +the source-loaded native terminal editor without adding it to the default app: + +```sh +make -C prototypes/connections BUILD=build/hybrid STORE=store-paged.c editor source-tui +python3 prototypes/connections/package_native.py \ + --build prototypes/connections/build/hybrid --hybrid-storage --with-tui \ + --output /tmp/pure-hybrid-tui.tar.gz +python3 prototypes/connections/check_package.py /tmp/pure-hybrid-tui.tar.gz +``` + +Use a new store directory for this profile; large documents use `PUREP001` +and need the offline exporter above before the default store can read them. + +For a same-process connection between real document stores, build the selected +`storage.pure` mirror and send one source snapshot to one or more destinations: + +```sh +make -C prototypes/connections mirror +prototypes/connections/build/pure-mirror \ + /path/to/source-store document /path/to/target-store document 0 +make -C prototypes/connections check-mirror +``` + +The final number is the expected target revision. Up to eight targets may be +specified as store/key/revision triples. Each target commits independently; +the JSON result reports conflicts and partial success. The mapped source body +stays alive until every forwarded target releases it. No socket or additional +target input buffer is involved, and this host is a one-shot command rather +than a long-running application supervisor. An optional leading +`--target-budget BYTES` bounds each destination's admitted body size; an +insufficient budget rejects the transfer before any document write. + +The fixed CLI and TUI contain one build-time prepared `editor.pure` and do not +reload a changed definition. The source TUI loads `.pure` at startup; edit the +file and start a new session to apply a different definition. The Workbench +scalar-native build additionally requires Zig 0.15.2; the GTK and TLS hosts +need their platform libraries. +On Linux, `make -C prototypes/connections check-copy-range` compares an +experimental large-extent file copy path with the ordinary bounded-scratch +build. It needs `strace`, `findmnt` and GNU size tools and has only been validated on the +recorded Linux kernel and filesystem. +macOS source compilation and execution are not yet verified. This preview is +not a signed release, does not bundle system libraries, and does not establish +a redistribution license. diff --git a/prototypes/connections/async-core-budget.json b/prototypes/connections/async-core-budget.json new file mode 100644 index 0000000..d2d4c0b --- /dev/null +++ b/prototypes/connections/async-core-budget.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-owner-thread-async-effects", + "max_text_bytes": 6144, + "max_data_bytes": 0, + "max_bss_bytes": 0, + "layout_max_bytes": { + "domain_bytes": 120, + "work_slot_bytes": 216, + "ticket_bytes": 24, + "pointer_bytes": 8, + "effect_bytes": 72 + }, + "archive_members": ["runtime-async.o"], + "external_symbols_allowed": ["memcpy", "memmove", "memset", "strlen"], + "scope": "Selected compact owner-thread completion extension without opt-in PURE_RUNTIME_METRICS. Host I/O and worker stacks are excluded. The default execution core and fixed document packages do not link this profile." +} diff --git a/prototypes/connections/async-server-budget.json b/prototypes/connections/async-server-budget.json new file mode 100644 index 0000000..b1664fc --- /dev/null +++ b/prototypes/connections/async-server-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-selected-read-write-document-http", + "binaries": { + "pure-connections-async": { + "max_text_bytes": 49152, + "max_data_bytes": 1200, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Optional one-read/one-write-worker HTTP host on the recorded Linux AArch64/GCC 14 toolchain. Section limits exclude the two 128 KiB worker stacks, libc, heap, mapped files and kernel buffers. Default CLI and HTTP do not link this option. Inspect process RSS and VM separately." +} diff --git a/prototypes/connections/check.py b/prototypes/connections/check.py new file mode 100644 index 0000000..7719537 --- /dev/null +++ b/prototypes/connections/check.py @@ -0,0 +1,552 @@ +#!/usr/bin/env python3 +"""Exercise local TCP, immutable byte extents, and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import fcntl +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import struct +import subprocess +import sys +import tempfile +import time +from check_edits import check_edits + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, storage, budget=4 * 1024 * 1024, timeout=5000, program=None): + self.process = subprocess.Popen([str(binary), '--store', str(storage), '--port', '0', + '--program', str(program or ROOT / 'examples/storage.pure'), + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory, editor=None): + storage = directory / 'objects' + checks, records = [], [] + with serving(binary, storage) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + with (storage / '.writer.lock').open('r+b') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + fcntl.lockf(lock, fcntl.LOCK_UN) + broken = storage / 'obj-broken' + broken.write_bytes(b'incomplete') + assert rpc(server, 'GET', 'broken')[0] == 500 + assert rpc(server, 'PUT', 'broken', b'failure', 0)[0] == 500 + assert broken.read_bytes() == b'incomplete' + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('writer contention and invalid extent fail without overwriting data') + + with serving(binary, storage) as second_writer: + with ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(rpc, target, 'PUT', 'race', value, 0) + for target, value in [(server, b'first'), (second_writer, b'second')]] + statuses = [task.result()[0] for task in tasks] + assert statuses.count(200) == 1 and all(status in [200, 412, 503] for status in statuses) + status, headers, body = rpc(server, 'GET', 'race') + assert status == 200 and headers['ETag'] == '"1"' and body in [b'first', b'second'] + records.append(second_writer.stats) + checks.append('two native writers cannot both create the same expected revision') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the immutable file whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock storage. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + wide = storage / 'obj-wide_revision' + raw = wide.read_bytes() + wide.write_bytes(raw[:8] + struct.pack('= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + prepared = authored_connections(binary, directory, storage, records, checks) + fields = field_connections(binary, directory, storage, records, checks) + edits = check_edits(editor, binary, ROOT, directory, serving, rpc, header, records, checks) if editor else None + return {'kind': 'prepared-connection-correctness-not-performance', 'checks': checks, + 'prepared_connections': prepared, + 'field_connections': fields, + 'document_edits': edits, + 'server_runs': records, 'storage': server.ready['storage']} + + +def authored_connections(binary, directory, storage, records, checks): + get = 'on get { let o = store.read(request.key); reply(o, o.body); }' + put = 'on put { let s = store.write(request.key, request.revision, request.body); reply(s); }' + + def author(name, source): + path = directory / name + path.write_text(source) + return path + + def explain(path): + return json.loads(subprocess.check_output([str(binary), '--check-program', str(path)], text=True)) + + with serving(binary, storage) as writer: + assert rpc(writer, 'PUT', 'short_parent', bytes(range(48)), 0)[0] == 200 + assert rpc(writer, 'PUT', 'short_unused', bytes(range(80)), 0)[0] == 200 + records.append(writer.stats) + excerpt = ROOT / 'examples/excerpt.pure' + prepared = {'excerpt': explain(excerpt)} + assert prepared['excerpt']['read'] == { + 'to_end': False, 'offset': 32, 'length': 16, 'minimum_source_bytes': 80} + with serving(binary, storage, budget=16, program=excerpt) as selected: + status, headers, body = rpc(selected, 'GET', 'large') + assert status == 200 and body == bytes(range(32, 48)) and headers['ETag'] == '"3"' + # Final [32,48) fits, but the authored parent [16,80) does not. + assert rpc(selected, 'GET', 'short_parent')[0] == 413 + assert rpc(selected, 'GET', 'missing')[0] == 404 + assert selected.stats['peak_retained_payload_bytes'] == 16 + records.append(selected.stats) + checks.append('authored nested read maps only final demand while preserving parent bounds and revision') + + unused = author('unused.pure', 'on get { let o = store.read(request.key); ' + 'let unused = range(o.body, 0, 96); let body = range(o.body, 32, 16); reply(o, body); } ' + put) + with serving(binary, storage, budget=16, program=unused) as guarded: + assert rpc(guarded, 'GET', 'short_unused')[0] == 413 + assert rpc(guarded, 'GET', 'large')[2] == bytes(range(32, 48)) + records.append(guarded.stats) + checks.append('unused checked view still fails even when response range would be valid') + + select_write = author('write.pure', get + ' on put { let a = range(request.body, 2, 10); ' + 'let b = range(a, 3, 4); let alias = b; ' + 'let saved = store.write(request.key, request.revision, alias); reply(saved, b); }') + prepared['write'] = explain(select_write) + with serving(binary, storage, program=select_write) as writer: + status, headers, body = rpc(writer, 'PUT', 'selected', b'0123456789AB', 0) + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'GET', 'selected')[2] == b'5678' + assert rpc(writer, 'PUT', 'selected', b'012345678', 1)[0] == 413 + status, headers, body = rpc(writer, 'GET', 'selected') + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'PUT', 'selected', b'0123456789AB', 0)[0] == 412 + records.append(writer.stats) + checks.append('authored write and reply share selected input; invalid parent prevents the storage effect') + + metadata = author('metadata.pure', 'on get { let o = store.read(request.key); reply(o); } ' + put) + with serving(binary, storage, budget=1, program=metadata) as meta: + status, headers, body = rpc(meta, 'GET', 'large') + assert status == 200 and headers['ETag'] == '"3"' and body == b'' + assert rpc(meta, 'GET', 'absent')[0] == 404 + assert rpc(meta, 'GET', 'broken')[0] == 500 + assert meta.stats['peak_retained_payload_bytes'] == 0 + records.append(meta.stats) + checks.append('body-free response preserves read existence and malformed-object errors') + + echo = author('echo.pure', get + ' on put { let body = request.body; ' + 'let saved = store.write(request.key, request.revision, body); reply(saved, body); }') + payload = bytes(range(256)) * 4096 + with serving(binary, storage, budget=len(payload), program=echo) as echoed: + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo', len(payload), b'If-None-Match: *\r\n') + payload) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.read(1) == payload[:1] + assert rpc(echoed, 'GET', 'binary')[0] == 503 + assert response.read() == payload[1:] + response.close() + assert rpc(echoed, 'GET', 'binary')[2] == b'new' + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo_abort', len(payload), b'If-None-Match: *\r\n') + payload) + assert sock.recv(1) + assert rpc(echoed, 'GET', 'binary')[0] == 503 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + deadline = time.monotonic() + 3 + while rpc(echoed, 'GET', 'binary')[0] == 503: + assert time.monotonic() < deadline + time.sleep(0.01) + assert rpc(echoed, 'GET', 'echo_abort')[2] == payload + records.append(echoed.stats) + checks.append('input-backed response survives partial send and releases on finish or abort without undoing commit') + + bad = author('invalid.pure', get + get + put) + untouched = directory / 'must_not_exist' + rejected = subprocess.run([str(binary), '--program', str(bad), '--store', str(untouched)], + capture_output=True, text=True, timeout=10) + assert rejected.returncode == 2 and 'duplicate handler' in rejected.stderr and not untouched.exists() + checks.append('invalid authored program fails preparation before creating storage or accepting requests') + return prepared + + +def field_connections(binary, directory, storage, records, checks): + program = ROOT / 'examples/selection.pure' + plans = {'selection': json.loads(subprocess.check_output( + [str(binary), '--check-program', str(program)], text=True))} + assert plans['selection']['runtime_view_steps'] == 1 + assert plans['selection']['steps'][0] == { + 'source_step': 0, 'source': {'offset': 8, 'length': 0, 'to_end': True}, + 'offset': {'value': 0, 'from_u32le': True}, 'length': {'value': 4, 'from_u32le': True}} + document = '처음🙂 Pure는 필드를 연결한다.\n끝' + document_file = directory / 'document.txt' + document_file.write_bytes(document.encode('utf-8')) + with serving(binary, storage, program=program) as selected: + command = [sys.executable, str(ROOT / 'examples/select-text.py'), str(document_file), + '--key', 'text_selection', '--start', '2', '--end', '12', '--url', f'http://127.0.0.1:{selected.port}'] + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, (result.stdout, result.stderr) + expected = document[2:12].encode('utf-8') + assert json.loads(result.stdout) == {'revision': '1', 'selected': document[2:12], 'stored_bytes': len(expected)} + assert rpc(selected, 'GET', 'text_selection')[2] == expected + checks.append('text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes') + + # Validation precedes the write: even a stale header cannot expose or + # commit partial input, and prior content/revision survive all failures. + invalid = [b'\0' * n for n in range(8)] + [ + struct.pack('' in line] + copies = [line for line in lines if 'copy_file_range(' in line] + writes = [line for line in lines if 'write(' in line and '/.pending-' in line] + assert all(SUCCESS.search(line) for line in reads + copies + writes) + + measured_store = root / (binary.parent.name + '-rss') + measured = [binary, '--program', HERE / 'examples/editor.pure', + '--store', measured_store] + run(*measured, 'edit', 'large', 0, 0, 0, initial) + usage = json.loads(run(helper, *measured, 'edit', 'large', 1, 1_048_575, 1, patch)) + assert usage['exit_code'] == 0 + assert run(*measured, 'show', 'large') == expected + return {'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'result_sha256': hashlib.sha256(expected).hexdigest(), + 'object_pread_calls': len(reads), + 'copy_file_range_calls': len(copies), + 'copy_file_range_bytes': sum(int(SUCCESS.search(line)[1]) for line in copies), + 'pending_write_calls': len(writes), + 'pending_write_bytes': sum(int(SUCCESS.search(line)[1]) for line in writes), + 'one_mib_splice_peak_rss_kib': usage['max_rss_kib']} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--baseline', type=Path, required=True) + parser.add_argument('--candidate', type=Path, required=True) + parser.add_argument('--baseline-helper', type=Path, required=True) + parser.add_argument('--candidate-helper', type=Path, required=True) + parser.add_argument('--scratch-dir', type=Path, default=HERE / 'build', + help='parent filesystem for the actual document stores') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + if platform.system() != 'Linux': + parser.error('strace and wait4 RSS evidence require Linux') + args.scratch_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='pure-copy-path-', + dir=args.scratch_dir.resolve()) as name: + root = Path(name) + filesystem = run('findmnt', '-T', root, '-no', 'FSTYPE').decode().strip().splitlines()[0] + initial, patch = root / 'initial', root / 'patch' + initial.write_bytes(b'A' * 1_048_576) + patch.write_bytes(b'Z') + baseline = inspect(args.baseline.resolve(), args.baseline_helper.resolve(), + root, initial, patch) + candidate = inspect(args.candidate.resolve(), args.candidate_helper.resolve(), + root, initial, patch) + assert baseline['result_sha256'] == candidate['result_sha256'] + assert baseline['copy_file_range_calls'] == 0 + assert candidate['copy_file_range_bytes'] == 1_048_575 + assert candidate['object_pread_calls'] < baseline['object_pread_calls'] + result = {'profile': 'linux-immutable-one-mib-last-byte-splice', + 'platform': platform.platform(), 'baseline': baseline, + 'candidate': candidate, + 'filesystem': filesystem, + 'scratch_parent': str(args.scratch_dir.resolve()), + 'scope': 'Successful object-file syscalls and child peak RSS; excludes physical device writes, page cache and proof of lower latency.'} + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_core.py b/prototypes/connections/check_core.py new file mode 100644 index 0000000..321896e --- /dev/null +++ b/prototypes/connections/check_core.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Fail when the selected execution core grows past its reviewed cost boundary. + +This is a release-build structural check using GNU-compatible ar/nm/size. Run it +separately from correctness/sanitizer tests; instrumented objects have other costs. +""" +import argparse +import hashlib +import json +from pathlib import Path +import platform +import subprocess + +ROOT = Path(__file__).resolve().parent + + +def run(*args): + return subprocess.check_output([str(arg) for arg in args], text=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', type=Path, required=True) + parser.add_argument('--layout', type=Path, required=True) + parser.add_argument('--budget', type=Path, default=ROOT / 'core-budget.json') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + budget = json.loads(args.budget.read_text()) + archive = args.archive.resolve() + members = run('ar', 't', archive).splitlines() + layout = json.loads(run(args.layout.resolve())) + size_output = run('size', archive) + rows = [line.split() for line in size_output.splitlines() if line.split() and line.split()[0].isdigit()] + if len(rows) != len(members): + parser.error('unsupported size output; no footprint claim was made') + sizes = {name: sum(int(row[i]) for row in rows) for i, name in enumerate(('text', 'data', 'bss'))} + defined = {line.split()[-1] for line in run('nm', '-g', '--defined-only', archive).splitlines() + if len(line.split()) >= 3} + undefined = {line.split()[-1] for line in run('nm', '-u', archive).splitlines() + if len(line.split()) == 2 and line.split()[0] == 'U'} + external = sorted(undefined - defined) + failures = [] + if sorted(members) != sorted(budget['archive_members']): + failures.append('unexpected archive members') + for name, value in sizes.items(): + if value > budget[f'max_{name}_bytes']: + failures.append(f'{name}: {value} > {budget[f"max_{name}_bytes"]}') + for name, maximum in budget['layout_max_bytes'].items(): + if layout[name] > maximum: + failures.append(f'{name}: {layout[name]} > {maximum}') + extra = set(external) - set(budget['external_symbols_allowed']) + if extra: + failures.append(f'unexpected dependencies: {sorted(extra)}') + result = {'passed': not failures, 'failures': failures, 'budget': budget, + 'layout': layout, 'sections': sizes, 'members': members, + 'external_symbols': external, 'size_output': size_output, + 'archive_sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), + 'layout_binary_sha256': hashlib.sha256(args.layout.resolve().read_bytes()).hexdigest(), + 'budget_sha256': hashlib.sha256(args.budget.read_bytes()).hexdigest(), + 'platform': platform.platform(), 'size_tool': run('size', '--version').splitlines()[0]} + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + if failures: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_document_peer.py b/prototypes/connections/check_document_peer.py new file mode 100644 index 0000000..1472ed7 --- /dev/null +++ b/prototypes/connections/check_document_peer.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Real TLS, document work, external client and CLI interop for the optional peer.""" +import argparse +import json +from pathlib import Path +import select +import socket +import ssl +import struct +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parent + + +def openssl(*args): + subprocess.run(['openssl', *map(str, args)], check=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def certificates(root): + ca = root / 'ca.pem' + openssl('req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-days', '1', '-subj', '/CN=Pure test CA', + '-addext', 'basicConstraints=critical,CA:TRUE', + '-addext', 'keyUsage=critical,keyCertSign,cRLSign', + '-keyout', root / 'ca.key', '-out', ca) + for role, name, eku in [('server', 'localhost', 'serverAuth'), + ('client', 'pure-client', 'clientAuth'), + ('other', 'wrong-client', 'clientAuth')]: + ext = root / (role + '.ext') + ext.write_text('basicConstraints=critical,CA:FALSE\n' + 'keyUsage=critical,digitalSignature\n' + f'subjectAltName=DNS:{name}\nextendedKeyUsage={eku}\n') + openssl('req', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-subj', '/CN=' + name, '-keyout', root / (role + '.key'), + '-out', root / (role + '.csr')) + openssl('x509', '-req', '-in', root / (role + '.csr'), '-CA', ca, + '-CAkey', root / 'ca.key', '-CAcreateserial', '-days', '1', + '-extfile', ext, '-out', root / (role + '.pem')) + return ca + + +def start(binary, program, store, root, ca): + proc = subprocess.Popen([str(binary), 'serve', '127.0.0.1', '0', str(program), + str(store), str(ca), str(root / 'server.pem'), + str(root / 'server.key'), 'pure-client'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1) + try: + assert select.select([proc.stdout], [], [], 5)[0], 'server did not become ready' + line = proc.stdout.readline() + assert line.startswith('READY '), (line, proc.poll()) + return proc, int(line.split()[1]) + except BaseException as error: + proc.kill() + stdout, stderr = proc.communicate(timeout=3) + raise AssertionError(f'server start failed: {error}; stdout={stdout!r}, stderr={stderr!r}') from error + + +def stop(proc): + proc.terminate() + stdout, stderr = proc.communicate(timeout=5) + assert proc.returncode == 0, (proc.returncode, stdout, stderr) + + +def connect(root, ca, port, *, cert='client', alpn='pure-doc/1', name='localhost'): + ctx = ssl.create_default_context(cafile=str(ca)) + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ctx.maximum_version = ssl.TLSVersion.TLSv1_3 + if cert: + ctx.load_cert_chain(str(root / (cert + '.pem')), str(root / (cert + '.key'))) + ctx.set_alpn_protocols([alpn]) + plain = socket.create_connection(('127.0.0.1', port), timeout=5) + try: + secure = ctx.wrap_socket(plain, server_hostname=name) + secure.settimeout(7) + if secure.selected_alpn_protocol() != alpn: + raise ssl.SSLError('ALPN mismatch') + return secure + except BaseException: + plain.close() + raise + + +def exact(sock, count): + parts = [] + while count: + part = sock.recv(count) + if not part: + raise EOFError('unexpected EOF') + parts.append(part) + count -= len(part) + return b''.join(parts) + + +def hello(sock, program): + import hashlib + assert exact(sock, 40) == b'PDOC\x01\0\0\0' + hashlib.sha256(program.read_bytes()).digest() + + +def request(sock, op, key, expected=0, body=b'', *, pieces=False, want_body=True): + key = key.encode('ascii') + wire = struct.pack('!BBQI', op | (0x80 if want_body else 0), len(key), + expected, len(body)) + key + body + if pieces: + for at in range(0, len(wire), 4093): + sock.sendall(wire[at:at + 4093]) + else: + sock.sendall(wire) + status, revision, length = struct.unpack('!BQI', exact(sock, 13)) + assert length <= 1450000, length + return status, revision, exact(sock, length if want_body else 0), length + + +def edit(offset, remove, inserted): + return struct.pack(' 0 + children = Path(f'/proc/{tracer.pid}/task/{tracer.pid}/children') + pids = children.read_text().split() + assert len(pids) == 1, pids + time.sleep(seconds) + os.kill(int(pids[0]), signal.SIGTERM) + _, error = tracer.communicate(timeout=10) + assert tracer.returncode == 0, (tracer.returncode, error[-1000:]) + captured = trace.read_text() + calls = [line for line in captured.splitlines() + if line.startswith(('poll(', 'ppoll('))] + values = [] + for line in calls: + if line.startswith('ppoll('): + if re.search(r'\],\s*\d+,\s*NULL,', line): + values.append(-1) + elif match := re.search(r'\{tv_sec=(\d+), tv_nsec=(\d+)\}', line): + values.append(int(match.group(1)) * 1000 + + int(match.group(2)) // 1_000_000) + elif match := re.search(r',\s*(-?\d+)\)\s+=', line): + values.append(int(match.group(1))) + return {'poll_calls': len(calls), 'timeout_values_ms': sorted(set(values)), + 'trace_excerpt': captured[:500], + 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest()} + finally: + if tracer.poll() is None: + os.killpg(tracer.pid, signal.SIGTERM) + try: + tracer.communicate(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(tracer.pid, signal.SIGKILL) + tracer.communicate(timeout=5) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--before', type=Path, required=True) + parser.add_argument('--after', type=Path, required=True) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + seconds = 1.25 + with tempfile.TemporaryDirectory(prefix='pure-idle-before-') as directory: + baseline = args.before.resolve() + if baseline.name.endswith('.tar.gz'): + with tarfile.open(baseline, 'r:gz') as archive: + body = archive.extractfile('pure-document/bin/pure-connections-fixed').read() + baseline = Path(directory) / 'pure-connections-fixed' + baseline.write_bytes(body) + baseline.chmod(0o755) + before = observe(baseline, seconds) + after = observe(args.after.resolve(), seconds) + assert before['poll_calls'] >= 10, before + assert after['poll_calls'] <= 2, after + assert -1 in after['timeout_values_ms'], after + result = {'profile': 'idle-http-host-poll-syscalls', + 'observation_seconds': seconds, 'before': before, 'after': after, + 'deadline_driven_idle_wait': 'pass'} + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_micro_memory.py b/prototypes/connections/check_micro_memory.py new file mode 100644 index 0000000..95fad1a --- /dev/null +++ b/prototypes/connections/check_micro_memory.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Check whole-process RSS for the selected CLI document workload.""" +import argparse +import hashlib +import json +from pathlib import Path +import platform +import subprocess +import tempfile + +HERE = Path(__file__).resolve().parent + + +def measure(helper, command): + result = subprocess.run([helper, *command], capture_output=True, text=True, + check=True, timeout=15) + sample = json.loads(result.stdout) + if sample['exit_code']: + raise AssertionError(f'workload failed: {command}: {sample}') + return sample + + +def check(build, fixed=False, portable=False): + budget_path = HERE / 'memory-budget.json' + budget = json.loads(budget_path.read_text()) + if not portable and (platform.system() != 'Linux' or platform.machine() != 'aarch64'): + raise RuntimeError('reviewed memory profile requires Linux AArch64') + binary = (build / ('pure-edit-fixed' if fixed else 'pure-edit')).resolve() + helper = (build / 'measure-peak-rss').resolve() + program = HERE / 'examples/editor.pure' + with tempfile.TemporaryDirectory(prefix='pure-micro-memory-') as name: + root = Path(name) + small, large = root / 'small', root / 'large' + small.write_bytes(b'A') + large.write_bytes(b'B' * 1_048_576) + store = root / 'store' + base = [binary] + if not fixed: + base += ['--program', program] + base += ['--store', store] + raw_samples = { + 'small_edit_rss_kib': measure(helper, [*base, 'edit', 'small', '0', '0', '0', small]), + 'one_mib_edit_rss_kib': measure(helper, [*base, 'edit', 'large', '0', '0', '0', large]), + 'one_mib_show_rss_kib': measure(helper, [*base, 'show', 'large']), + } + samples = {name: sample['max_rss_kib'] for name, sample in raw_samples.items()} + shown = subprocess.run([*map(str, base), 'show', 'large'], + capture_output=True, check=True, timeout=15) + if shown.stdout != large.read_bytes(): + raise AssertionError('measured document was not stored correctly') + if portable: + replacement = root / 'replacement' + replacement.write_bytes(b'Z') + raw_samples['one_mib_splice_rss_kib'] = measure( + helper, [*base, 'edit', 'large', '1', '524288', '1', replacement]) + samples['one_mib_splice_rss_kib'] = raw_samples['one_mib_splice_rss_kib']['max_rss_kib'] + edited = subprocess.run([*map(str, base), 'show', 'large'], + capture_output=True, check=True, timeout=15) + expected = large.read_bytes()[:524288] + b'Z' + large.read_bytes()[524289:] + if edited.stdout != expected: + raise AssertionError('one-byte revision edit did not survive a new process') + failures = [] if portable else [f'{name}: {value} > {budget["max_" + name]}' + for name, value in samples.items() if value > budget['max_' + name]] + if portable: + scope = ('Child-process peak RSS for native CLI document creation, read and ' + 'one-byte revision edit; includes libc and the selected program form, ' + 'excludes filesystem cache, kernel buffers, TLS, GUI and other apps. ' + 'No cross-platform memory ceiling is asserted.') + else: + scope = budget['scope'] + if fixed: + scope = scope.replace('program parsing', 'the embedded prepared program') + return {'passed': not failures, 'failures': failures, + 'profile': 'portable-measurement-no-budget' if portable else budget['profile'], + 'variant': 'fixed-program' if fixed else 'source-program', + 'scope': scope, 'samples': samples, + 'raw_samples': raw_samples if portable else None, + 'budget_sha256': None if portable else hashlib.sha256(budget_path.read_bytes()).hexdigest(), + 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'binary_bytes': binary.stat().st_size, + 'platform': platform.platform()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, required=True) + parser.add_argument('--fixed', action='store_true', + help='measure the parser-free fixed-program editor') + parser.add_argument('--portable', action='store_true', + help='measure native document work without a Linux-specific RSS ceiling') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + result = check(args.build, args.fixed, args.portable) + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output) + print(output, end='') + if not result['passed']: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_mirror.py b/prototypes/connections/check_mirror.py new file mode 100644 index 0000000..ed46fa8 --- /dev/null +++ b/prototypes/connections/check_mirror.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Run a real document through multiple in-process Pure domains and stores.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile + + +def run(*args, code=0): + result = subprocess.run([str(arg) for arg in args], capture_output=True, timeout=30) + if result.returncode != code: + raise AssertionError((result.args, result.returncode, code, result.stderr[-500:])) + return result + + +def edit(binary, store, key, revision, offset, remove, file): + result = run(binary, '--store', store, 'edit', key, revision, offset, remove, file) + return json.loads(result.stdout)['revision'] + + +def show(binary, store, key, revision, body): + result = run(binary, '--store', store, 'show', key) + assert result.stdout == body + assert json.loads(result.stderr)['revision'] == str(revision) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--mirror', type=Path, required=True) + parser.add_argument('--editor', type=Path, required=True) + parser.add_argument('--rss-helper', type=Path) + parser.add_argument('--scratch-parent', type=Path) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + mirror, editor = args.mirror.resolve(), args.editor.resolve() + if args.scratch_parent: + args.scratch_parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='pure-mirror-', + dir=args.scratch_parent.resolve() if args.scratch_parent else None) as name: + root = Path(name) + source, left, right = root / 'source', root / 'left', root / 'right' + initial = b'A' * 1_048_576 + changed = initial[:-1] + b'Z' + input_file, patch, empty = root / 'initial', root / 'patch', root / 'empty' + input_file.write_bytes(initial) + patch.write_bytes(b'Z') + empty.write_bytes(b'') + assert edit(editor, source, 'document', 0, 0, 0, input_file) == '1' + first = json.loads(run(mirror, source, 'document', left, 'document', 0, + right, 'document', 0).stdout) + assert first['source_revision'] == '1' and first['bytes'] == len(initial) + assert [target['status'] for target in first['targets']] == ['ok', 'ok'] + show(editor, left, 'document', 1, initial) + show(editor, right, 'document', 1, initial) + + assert edit(editor, source, 'document', 1, len(initial) - 1, 1, patch) == '2' + show(editor, right, 'document', 1, initial) + partial = json.loads(run(mirror, source, 'document', left, 'document', 1, + right, 'document', 0, code=3).stdout) + assert [target['status'] for target in partial['targets']] == ['ok', 'conflict'] + show(editor, left, 'document', 2, changed) + show(editor, right, 'document', 1, initial) + recovered = json.loads(run(mirror, source, 'document', right, 'document', 1).stdout) + assert recovered['targets'][0]['revision'] == '2' + show(editor, right, 'document', 2, changed) + + assert edit(editor, source, 'empty', 0, 0, 0, empty) == '1' + assert json.loads(run(mirror, source, 'empty', left, 'empty', 0).stdout)['bytes'] == 0 + show(editor, left, 'empty', 1, b'') + zero_budget = root / 'zero-budget' + assert json.loads(run(mirror, '--target-budget', 0, source, 'empty', + zero_budget, 'empty', 0).stdout)['targets'][0]['status'] == 'ok' + show(editor, zero_budget, 'empty', 1, b'') + low_budget = root / 'low-budget' + refused = json.loads(run(mirror, '--target-budget', 1024, source, + 'document', low_budget, 'document', 0, code=2).stdout) + assert refused == {'admission_status': 'budget', 'target_index': 0} + assert not list(low_budget.glob('obj-*')) + + rejected_store = root / 'rejected' + rejected = json.loads(run(mirror, source, 'document', rejected_store, + 'document', 0, right, 'bad/key', 0, code=2).stdout) + assert rejected == {'admission_status': 'invalid', 'target_index': 1} + assert not list(rejected_store.glob('obj-*')) + show(editor, right, 'document', 2, changed) + missing_store = root / 'missing-target' + missing = json.loads(run(mirror, source, 'absent', missing_store, + 'document', 0, code=2).stdout) + assert missing == {'source_status': 'not_found'} and not missing_store.exists() + + measured = None + fanout = [root / f'fanout-{index}' for index in range(8)] + fanout_args = [item for store in fanout for item in (store, 'document', 0)] + if args.rss_helper: + third, fourth = root / 'third', root / 'fourth' + measured = json.loads(run(args.rss_helper.resolve(), mirror, source, + 'document', third, 'document', 0, + fourth, 'document', 0).stdout) + assert measured['exit_code'] == 0 + show(editor, third, 'document', 1, changed) + show(editor, fourth, 'document', 1, changed) + fanout_rss = json.loads(run(args.rss_helper.resolve(), mirror, source, + 'document', *fanout_args).stdout) + assert fanout_rss['exit_code'] == 0 + else: + fanout_result = json.loads(run(mirror, source, 'document', *fanout_args).stdout) + assert len(fanout_result['targets']) == 8 + assert all(target['status'] == 'ok' for target in fanout_result['targets']) + fanout_rss = None + for store in fanout: + show(editor, store, 'document', 1, changed) + result = {'passed': True, 'source_body_sha256': hashlib.sha256(changed).hexdigest(), + 'mirror_sha256': hashlib.sha256(mirror.read_bytes()).hexdigest(), + 'editor_sha256': hashlib.sha256(editor.read_bytes()).hexdigest(), + 'source_one_mib_to_two_domains': first, + 'independent_partial_conflict': partial, + 'conflict_recovered_revision': recovered['targets'][0]['revision'], + 'empty_body_forwarded': True, + 'target_budget_checked_before_document_write': True, + 'all_admissions_before_document_writes': True, + 'source_failure_before_target_creation': True, + 'one_mib_two_target_peak_rss': measured, + 'one_mib_eight_targets_verified': True, + 'one_mib_eight_target_peak_rss': fanout_rss} + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_package.py b/prototypes/connections/check_package.py new file mode 100644 index 0000000..84adc05 --- /dev/null +++ b/prototypes/connections/check_package.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Extract a binary preview elsewhere and run its real document paths.""" +import argparse +import hashlib +import http.client +import json +from pathlib import Path +import select +import subprocess +import struct +import tarfile +import tempfile + +HERE = Path(__file__).resolve().parent + + +def run(*args): + result = subprocess.run([str(arg) for arg in args], capture_output=True, timeout=90) + if result.returncode: + raise AssertionError((result.args, result.returncode, result.stdout[-1000:], result.stderr[-1000:])) + return result + + +def check_http(server_binary, binary, program, root): + store = root / 'http-store' + insert = root / 'http-insert' + insert.write_bytes(b'hello Pure') + program_args = [] if program is None else ['--program', program] + run(binary, *program_args, '--store', store, + 'edit', 'note', '0', '0', '0', insert) + server = subprocess.Popen([str(server_binary), *map(str, program_args), + '--store', str(store), '--port', '0'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + ready, _, _ = select.select([server.stdout], [], [], 5) + assert ready, 'packaged HTTP host did not start' + port = json.loads(server.stdout.readline())['port'] + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + try: + body = struct.pack('= 7 + checks.append('relocated terminal editor saves UTF-8 and one MiB documents') + return {'archive_sha256': digest, 'archive_bytes': archive.stat().st_size, + 'selected_hosts': manifest['selected_hosts'], 'checks': checks} + + binary = package / 'bin/pure-edit' + program = package / 'share/editor.pure' + store = root / 'store' + insert = root / 'insert.bin' + insert.write_bytes(b'hello Pure') + written = run(binary, '--program', program, '--store', store, + 'edit', 'note', '0', '0', '0', insert) + assert json.loads(written.stdout) == {'revision': '1', 'reply_bytes': 10} + profile = manifest.get('storage_profile') + expected_magic = b'PUREP001' if profile == 'PUREP001-16k-page-research' else b'PUREB001' + assert (store / 'obj-note').read_bytes()[:8] == expected_magic + shown = run(binary, '--program', program, '--store', store, 'show', 'note') + assert shown.stdout == b'hello Pure' + checks.append('extracted native CLI creates and restores the real document') + if profile == 'PUREB001+PUREP001-hybrid-16k-research': + big = root / 'big.bin' + big.write_bytes(b'X' * (16 * 1024 + 1)) + run(binary, '--program', program, '--store', store, + 'edit', 'big', '0', '0', '0', big) + assert (store / 'obj-big').read_bytes()[:8] == b'PUREP001' + assert run(binary, '--program', program, '--store', store, + 'show', 'big').stdout == big.read_bytes() + checks.append('selected storage keeps small values inline and maps large values') + + changed = root / 'changed.pure' + source = program.read_text() + assert 'reply(saved, edit.insert);' in source + changed.write_text(source.replace('reply(saved, edit.insert);', 'reply(saved);')) + changed_store = root / 'changed-store' + changed_result = run(binary, '--program', changed, '--store', changed_store, + 'edit', 'note', '0', '0', '0', insert) + assert json.loads(changed_result.stdout) == {'revision': '1', 'reply_bytes': 0} + checks.append('editing the packaged .pure source changes the executed reply') + + if 'pure-connections' in manifest['selected_hosts']: + check_http(package / 'bin/pure-connections', binary, program, root) + checks.append('extracted HTTP host updates the same document as the native CLI') + + if 'pure-edit-tui' in manifest['selected_hosts']: + tui = json.loads(run('python3', HERE / 'check_tui.py', '--build', + package / 'bin', '--source', '--program', program).stdout) + assert 'source CLI and TUI read the same document' in tui['checks'] + checks.append('relocated source-loaded TUI and CLI share UTF-8, large and many-document paths') + + peer = None + assert not ({'pure-document-peer', 'pure-document-server'} <= + set(manifest['selected_hosts'])) + if 'pure-document-peer' in manifest['selected_hosts']: + peer = json.loads(run('python3', HERE / 'check_document_peer.py', '--peer', + package / 'bin/pure-document-peer', '--editor', binary, + '--program', program).stdout) + assert len(peer['checks']) >= 9 + checks.append('extracted C/Python TLS peers share a 1 MiB document and recover') + if 'pure-document-server' in manifest['selected_hosts']: + peer = json.loads(run('python3', HERE / 'check_document_server_package.py', + '--server', package / 'bin/pure-document-server', + '--editor', binary, '--program', program).stdout) + assert len(peer['checks']) >= 5 + checks.append('extracted server-only TLS peer and external client share a 1 MiB document and recover') + if 'pure-edit-window' in manifest['selected_hosts']: + key_switch = 'native-document-key-switch' in manifest.get('features', []) + window = json.loads(run('xvfb-run', '-a', '-s', '-screen 0 1280x800x24', + 'python3', HERE / 'check_window.py', '--build', + package / 'bin', '--program', program, + *([] if key_switch else ['--legacy-window'])).stdout) + assert window['one_mib_document_one_byte_edit'] == 'pass' + if key_switch: + assert window['multi_document_switch_preserves_draft'] == 'pass' + checks.append('extracted GTK window switches documents without losing drafts') + else: + checks.append('extracted archived GTK window edits the same document') + result = {'archive_sha256': digest, 'archive_bytes': archive.stat().st_size, + 'selected_hosts': manifest['selected_hosts'], 'checks': checks} + if peer is not None: + result['peer_checks'] = peer['checks'] + result['peer_idle_rss_kib'] = peer['server_idle_rss_kib'] + result['peer_after_large_work_rss_kib'] = peer['server_after_large_work_rss_kib'] + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('archive', type=Path) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + result = check(args.archive) + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_policy.py b/prototypes/connections/check_policy.py new file mode 100644 index 0000000..5fce560 --- /dev/null +++ b/prototypes/connections/check_policy.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Exercise an authored final-size guard through native source and fixed apps.""" +import argparse +import http.client +import json +from pathlib import Path +import select +import struct +import subprocess +import tempfile +from check_tui import Terminal +from check_document_peer import (certificates, connect, edit as peer_edit, + hello, request as peer_request, start, stop) + + +def run(binary, program, store, *args): + command = [str(binary)] + if program is not None: + command += ['--program', str(program)] + command += ['--store', str(store), *map(str, args)] + return subprocess.run(command, capture_output=True, timeout=15) + + +def edit(binary, program, store, payload, key, revision, offset, remove, body): + payload.write_bytes(body) + return run(binary, program, store, 'edit', key, revision, offset, remove, payload) + + +def result(process, status, revision): + assert process.returncode == status, (process.returncode, process.stderr) + channel = process.stdout if status == 0 else process.stderr + value = json.loads(channel) + assert value['revision'] == str(revision), value + if status: + assert value['error'] == 'limit' if status == 2 else value['error'] == 'conflict' + + +def body(binary, program, store, expected): + shown = run(binary, program, store, 'show', 'note') + assert shown.returncode == 0 and shown.stdout == expected + return int(json.loads(shown.stderr)['revision']) + + +class Server: + def __init__(self, binary, program, store): + command = [str(binary), '--store', str(store), '--port', '0'] + if program is not None: + command += ['--program', str(program)] + self.process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + ready, _, _ = select.select([self.process.stdout], [], [], 10) + if not ready: + self.process.kill() + raise AssertionError('document server did not start') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.port = json.loads(line)['port'] + + def request(self, method, data=b'', expected=None): + headers = {'Content-Length': str(len(data))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = ( + '*' if expected == 0 else f'"{expected}"') + connection = http.client.HTTPConnection('127.0.0.1', self.port, timeout=15) + try: + connection.request(method, '/objects/note', data, headers) + response = connection.getresponse() + return response.status, response.getheader('ETag'), response.read() + finally: + connection.close() + + def close(self): + self.process.terminate() + _, error = self.process.communicate(timeout=10) + assert self.process.returncode == 0, error[-1000:] + stats = json.loads(error) + assert stats['active'] == stats['retained_payload_bytes'] == 0 + + +def splice(offset, remove, insert): + return struct.pack(' limit: + failures.append(f'{name} {section}: {value} > {limit}') + if observed['needed'] != sorted(limits['needed']): + failures.append(f'{name} dependencies: {observed["needed"]} ' + f'!= {sorted(limits["needed"])}') + result = {'passed': not failures, 'failures': failures, + 'profile': budget['profile'], 'scope': budget['scope'], + 'budget_sha256': hashlib.sha256(args.budget.read_bytes()).hexdigest(), + 'platform': platform.platform(), 'binaries': binaries} + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + if failures: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_service_scale.py b/prototypes/connections/check_service_scale.py new file mode 100644 index 0000000..fb04baf --- /dev/null +++ b/prototypes/connections/check_service_scale.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Exercise the real document app across many objects and concurrent clients.""" +import argparse +from concurrent.futures import ThreadPoolExecutor +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile + +from check_fixed_server import Host, memory_kib, request, splice + +HERE = Path(__file__).resolve().parent +DOCUMENTS = 1024 +CLIENTS = 8 +BODY_BYTES = 8192 +SERVER_BUDGET = 1_048_576 +PEAK_RSS_LIMIT_KIB = 4096 + + +def document(index): + seed = f'document:{index:04d}:'.encode() + return (seed * ((BODY_BYTES + len(seed) - 1) // len(seed)))[:BODY_BYTES] + + +def client(host, client_number): + digests = [] + for index in range(client_number, DOCUMENTS, CLIENTS): + key = f'doc{index:04d}' + original = document(index) + created = request(host, 'PUT', key, splice(0, 0, original), 0) + assert created == (200, '"1"', original), (index, 'create', created[:2]) + opened = request(host, 'GET', key) + assert opened == (200, '"1"', original), (index, 'open', opened[:2]) + replacement = bytes((65 + index % 26,)) + edited = request(host, 'PUT', key, + splice(BODY_BYTES - 1, 1, replacement), 1) + assert edited == (200, '"2"', replacement), (index, 'edit', edited[:2]) + final = original[:-1] + replacement + restored = request(host, 'GET', key) + assert restored == (200, '"2"', final), (index, 'restore', restored[:2]) + digests.append((index, hashlib.sha256(final).digest())) + return digests + + +def run_variant(build, root, fixed, async_source=False): + assert not (fixed and async_source) + store = root / ('fixed' if fixed else 'source-async' if async_source else 'source') + binary = build / ('pure-connections-fixed' if fixed else + 'pure-connections-async' if async_source else 'pure-connections') + host = Host(binary, store, fixed, budget=SERVER_BUDGET) + idle_rss = memory_kib(host.process.pid) + idle_vm = memory_kib(host.process.pid, 'VmSize:') + idle_threads = memory_kib(host.process.pid, 'Threads:') + try: + with ThreadPoolExecutor(max_workers=CLIENTS) as pool: + batches = list(pool.map(lambda number: client(host, number), + range(CLIENTS))) + all_digests = sorted(item for batch in batches for item in batch) + assert len(all_digests) == DOCUMENTS + digest = hashlib.sha256(b''.join(value for _, value in all_digests)).hexdigest() + peak_rss = memory_kib(host.process.pid, 'VmHWM:') + peak_vm = memory_kib(host.process.pid, 'VmPeak:') + peak_threads = memory_kib(host.process.pid, 'Threads:') + finally: + stats = host.close() + editor = build / ('pure-edit-fixed' if fixed else 'pure-edit') + for index in (0, 127, 512, DOCUMENTS - 1): + key = f'doc{index:04d}' + args = [str(editor), '--store', str(store)] + if not fixed: + args += ['--program', str(HERE / 'examples/editor.pure')] + shown = subprocess.run([*args, 'show', key], capture_output=True, timeout=15) + replacement = bytes((65 + index % 26,)) + assert shown.returncode == 0, (index, shown.stderr) + assert shown.stdout == document(index)[:-1] + replacement, index + assert json.loads(shown.stderr)['revision'] == '2', index + objects = list(store.glob('obj-*')) + assert len(objects) == DOCUMENTS, len(objects) + assert stats['accepted'] == DOCUMENTS * 4, stats + assert stats['commits'] == DOCUMENTS * 2, stats + assert stats['gets'] == DOCUMENTS * 2, stats + assert stats['releases'] == DOCUMENTS * 4, stats + assert stats['budget_rejections'] == stats['expired'] == 0, stats + assert stats['peak_connections'] <= CLIENTS, stats + assert stats['peak_retained_payload_bytes'] <= SERVER_BUDGET, stats + assert peak_rss <= PEAK_RSS_LIMIT_KIB, peak_rss + return {'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), + 'document_digest': digest, 'stored_objects': len(objects), + 'stored_file_bytes': sum(path.stat().st_size for path in objects), + 'fresh_cli_restore_samples': 4, 'idle_rss_kib': idle_rss, + 'peak_rss_kib': peak_rss, 'idle_vm_kib': idle_vm, + 'peak_vm_kib': peak_vm, 'idle_threads': idle_threads, + 'peak_threads': peak_threads, 'stats': stats} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, default=HERE / 'build') + parser.add_argument('--output', type=Path) + parser.add_argument('--async-source', action='store_true', + help='also run the optional one-worker HTTP host') + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + build = args.build.resolve() + with tempfile.TemporaryDirectory(prefix='pure-document-scale-') as directory: + root = Path(directory) + fixed = run_variant(build, root, True) + source = run_variant(build, root, False) + async_source = run_variant(build, root, False, True) if args.async_source else None + assert fixed['document_digest'] == source['document_digest'] + if async_source: + assert source['document_digest'] == async_source['document_digest'] + result = {'profile': 'document-app-many-objects-concurrent-clients', + 'documents_per_variant': DOCUMENTS, 'clients': CLIENTS, + 'body_bytes': BODY_BYTES, 'operations_per_variant': DOCUMENTS * 4, + 'server_budget_bytes': SERVER_BUDGET, + 'peak_rss_limit_kib': PEAK_RSS_LIMIT_KIB, + 'same_final_documents': 'pass', 'fixed': fixed, 'source': source} + if async_source: + result['source_async'] = async_source + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_small_scan.py b/prototypes/connections/check_small_scan.py new file mode 100644 index 0000000..e5f3223 --- /dev/null +++ b/prototypes/connections/check_small_scan.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Reject directory-wide segment scans on the selected small-object edit path.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import tempfile + +HERE = Path(__file__).resolve().parent + + +def run(*args): + result = subprocess.run([str(value) for value in args], capture_output=True, timeout=20) + if result.returncode: + raise AssertionError((result.args, result.returncode, result.stderr[-500:])) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--editor', required=True, type=Path) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + editor = args.editor.resolve() + with tempfile.TemporaryDirectory(prefix='pure-small-scan-') as name: + root = Path(name) + store = root / 'store' + one = root / 'one' + one.write_bytes(b'A') + base = (editor, '--program', HERE / 'examples/editor.pure', '--store', store) + run(*base, 'edit', 'tiny', 0, 0, 0, one) + for index in range(256): + (store / f'unrelated-{index:03d}').write_bytes(b'') + trace = root / 'trace' + run('strace', '-qq', '-e', 'getdents64', '-yy', '-o', trace, + *base, 'edit', 'tiny', 1, 0, 1, one) + scans = [line for line in trace.read_text().splitlines() + if 'getdents64(' in line and str(store) in line] + assert not scans, scans + result = {'small_edit_store_directory_scans': 0, + 'unrelated_directory_entries': 256, + 'binary_sha256': hashlib.sha256(editor.read_bytes()).hexdigest()} + output = json.dumps(result, indent=2) + '\n' + if args.output: + if args.output.exists(): + parser.error('evidence file already exists') + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_source_package.py b/prototypes/connections/check_source_package.py new file mode 100644 index 0000000..5bc2f15 --- /dev/null +++ b/prototypes/connections/check_source_package.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Verify a source snapshot after safe extraction and a clean native build.""" +import argparse +import hashlib +import json +from pathlib import Path +import subprocess +import tarfile +import tempfile + +PREFIX = 'pure-source-preview/' + + +def run(*args, timeout=120): + result = subprocess.run([str(arg) for arg in args], capture_output=True, timeout=timeout) + if result.returncode: + raise AssertionError((result.args, result.returncode, + result.stdout[-2000:], result.stderr[-2000:])) + return result + + +def check(archive, release, peer, copy_range, flat_export, mirror, hybrid_tui): + archive = archive.resolve() + checks = [] + with tempfile.TemporaryDirectory(prefix='pure-source-extracted-') as directory: + root = Path(directory) + package = root / PREFIX[:-1] + with tarfile.open(archive, 'r:gz') as bundle: + members = bundle.getmembers() + names = [member.name for member in members] + assert len(names) == len(set(names)) + assert all(member.isfile() and member.name.startswith(PREFIX) and + '..' not in Path(member.name).parts for member in members) + for member in members: + destination = root / member.name + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(bundle.extractfile(member).read()) + manifest = json.loads((package / 'manifest.json').read_text()) + assert manifest['format'] == 'pure-source-preview-1' + assert set(names) == {PREFIX + name for name in manifest['files']} | {PREFIX + 'manifest.json'} + for name, metadata in manifest['files'].items(): + content = (package / name).read_bytes() + assert len(content) == metadata['bytes'] + assert hashlib.sha256(content).hexdigest() == metadata['sha256'] + checks.append('every extracted source file matches the manifest') + + connections = package / 'prototypes/connections' + run('make', '-C', connections, timeout=120) + default_outputs = {path.name for path in (connections / 'build').iterdir()} + assert default_outputs == {'runtime.o', 'libpure-execution.a', + 'pure-compile', 'embedded-plan.h', + 'pure-edit-fixed'}, default_outputs + checks.append('clean default build contains only the core, parser-free CLI and its build-time compiler') + run('make', '-C', connections, 'check-native-checkpoint', timeout=300) + checks.append('clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass') + run('make', '-C', connections, 'fixed-editor') + editor = connections / 'build/pure-edit' + fixed = connections / 'build/pure-edit-fixed' + program = connections / 'examples/editor.pure' + store = root / 'document-store' + insert = root / 'insert.bin' + insert.write_bytes(b'hello Pure') + written = run(editor, '--program', program, '--store', store, + 'edit', 'note', '0', '0', '0', insert) + assert json.loads(written.stdout) == {'revision': '1', 'reply_bytes': 10} + assert run(fixed, '--store', store, 'show', 'note').stdout == b'hello Pure' + checks.append('source-loaded CLI and parser-free CLI share the real stored document') + + changed = root / 'changed.pure' + source = program.read_text() + assert 'reply(saved, edit.insert);' in source + changed.write_text(source.replace('reply(saved, edit.insert);', 'reply(saved);')) + changed_store = root / 'changed-store' + changed_result = run(editor, '--program', changed, '--store', changed_store, + 'edit', 'note', '0', '0', '0', insert) + assert json.loads(changed_result.stdout) == {'revision': '1', 'reply_bytes': 0} + checks.append('editing .pure changes the native source-loaded reply') + + if peer: + run('make', '-C', connections, 'check-portable-peer', timeout=180) + checks.append('clean native TLS client and both server profiles pass authenticated document work') + + if copy_range: + run('make', '-C', connections, 'check-copy-range', timeout=240) + checks.append('clean selected Linux copy path passes app, scale, size, RSS and traced one-MiB splice') + + if flat_export: + run('make', '-C', connections, 'check-flat-export', timeout=180) + checks.append('clean selected page store exports mixed revisions to the default flat store') + + if mirror: + run('make', '-C', connections, 'check-mirror', timeout=180) + checks.append('clean one-MiB document fans out to two same-process Pure domains with independent conflict and release') + + if hybrid_tui: + run('make', '-C', connections, 'BUILD=build/hybrid', 'STORE=store-paged.c', + 'editor', 'source-tui', timeout=120) + run('python3', connections / 'check_tui.py', '--build', + connections / 'build/hybrid', '--source', timeout=120) + selected = root / 'hybrid-tui.tar.gz' + run('python3', connections / 'package_native.py', '--build', + connections / 'build/hybrid', '--hybrid-storage', '--with-tui', + '--output', selected, timeout=120) + run('python3', connections / 'check_package.py', selected, timeout=120) + checks.append('clean selected page store runs the source-loaded TUI and relocatable CLI+TUI package') + + if release: + run('make', '-C', connections, 'check-release', timeout=180) + checks.append('clean Linux release behavior, code, dependency and RSS gates pass') + run('make', '-C', connections, 'check-source-tui', timeout=120) + checks.append('clean source-loaded terminal editor PTY scenarios pass') + return {'archive_sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), + 'archive_bytes': archive.stat().st_size, 'source_files': len(manifest['files']), + 'checks': checks} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('archive', type=Path) + parser.add_argument('--release', action='store_true', help='also run Linux release gates') + parser.add_argument('--peer', action='store_true', + help='also build and exercise the optional TLS client and servers') + parser.add_argument('--copy-range', action='store_true', + help='also check the opt-in Linux file copy path with strace') + parser.add_argument('--flat-export', action='store_true', + help='also check selected paged-to-default document store export') + parser.add_argument('--mirror', action='store_true', + help='also check one-process document fan-out through two Pure domains') + parser.add_argument('--hybrid-tui', action='store_true', + help='also build and relocate the selected 16 KiB-page native terminal app') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + result = check(args.archive, args.release, args.peer, args.copy_range, + args.flat_export, args.mirror, args.hybrid_tui) + text = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text) + print(text, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_tui.py b/prototypes/connections/check_tui.py new file mode 100644 index 0000000..5328d02 --- /dev/null +++ b/prototypes/connections/check_tui.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Exercise the real terminal editor through a PTY and the same document CLI.""" +import argparse +import fcntl +import hashlib +import json +import locale +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import tempfile +import termios +import time + +HERE = Path(__file__).resolve().parent + + +def command(*args): + return subprocess.run([str(arg) for arg in args], capture_output=True, + timeout=20, check=True) + + +def utf8_locale(): + original = locale.setlocale(locale.LC_CTYPE) + candidates = [os.environ.get('LC_ALL'), os.environ.get('LC_CTYPE'), + os.environ.get('LANG'), 'C.UTF-8', 'en_US.UTF-8'] + try: + for candidate in candidates: + if not candidate: + continue + try: + locale.setlocale(locale.LC_CTYPE, candidate) + except locale.Error: + continue + if locale.nl_langinfo(locale.CODESET).upper().replace('-', '') == 'UTF8': + return candidate + finally: + locale.setlocale(locale.LC_CTYPE, original) + raise AssertionError('no UTF-8 locale available for terminal test') + + +def memory_kib(pid, field, portable=False): + if portable: + return None + for line in Path(f'/proc/{pid}/status').read_text().splitlines(): + if line.startswith(field): + return int(line.split()[1]) + raise AssertionError(field) + + +class Terminal: + def __init__(self, binary, program, store, key): + self.master, self.slave = pty.openpty() + fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + self.original = termios.tcgetattr(self.slave) + language = utf8_locale() + self.process = subprocess.Popen([str(binary), + *(['--program', str(program)] if program else []), + '--store', str(store), key], + stdin=self.slave, stdout=self.slave, stderr=self.slave, + env={**os.environ, 'LANG': language, + 'LC_ALL': language}) + self.wait_for(f'Pure | {key}'.encode()) + + def wait_for(self, marker, timeout=8): + seen = b'' + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ready, _, _ = select.select([self.master], [], [], 0.1) + if ready: + part = os.read(self.master, 65536) + seen = (seen + part)[-65536:] + if marker in seen: + return seen + if self.process.poll() is not None: + break + raise AssertionError((marker, self.process.poll(), seen[-2000:])) + + def type(self, data): + os.write(self.master, data) + + def resize(self, rows, columns): + fcntl.ioctl(self.slave, termios.TIOCSWINSZ, + struct.pack('HHHH', rows, columns, 0, 0)) + os.kill(self.process.pid, signal.SIGWINCH) + + def close(self, discard=False): + if self.process.poll() is None: + self.type(b'\x11') + if discard: + self.wait_for(b'Unsaved draft') + self.type(b'\x11') + self.process.wait(timeout=8) + assert self.process.returncode == 0, self.process.returncode + restored = termios.tcgetattr(self.slave) + assert restored == self.original, (restored, self.original) + os.close(self.master) + os.close(self.slave) + + def signal_close(self): + os.kill(self.process.pid, signal.SIGTERM) + self.process.wait(timeout=8) + assert self.process.returncode == 0, self.process.returncode + assert termios.tcgetattr(self.slave) == self.original + os.close(self.master) + os.close(self.slave) + + +def shown(editor, store, key): + result = command(*editor, '--store', store, 'show', key) + return result.stdout, json.loads(result.stderr)['revision'] + + +def wait_stored(editor, store, key, expected, revision): + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + try: + body, found_revision = shown(editor, store, key) + if body == expected and found_revision == str(revision): + return + except subprocess.CalledProcessError: + pass + time.sleep(0.05) + raise AssertionError((key, expected[:100], shown(editor, store, key))) + + +def check(build, rss_limit_kib, source, program_path, portable=False): + binary = (build / ('pure-edit-tui' if source else 'pure-edit-tui-fixed')).resolve() + program = (program_path or HERE / 'examples/editor.pure').resolve() if source else None + editor = [str((build / ('pure-edit' if source else 'pure-edit-fixed')).resolve())] + if source: + editor += ['--program', str(program)] + with tempfile.TemporaryDirectory(prefix='pure-tui-') as name: + root = Path(name) + store = root / 'store' + if source: + invalid = root / 'invalid.pure' + invalid.write_text('not a pure program\n') + for candidate, marker in ((invalid, b'Program:'), + (HERE / 'examples/storage.pure', b'Program needs')): + denied = subprocess.run([str(binary), '--program', str(candidate), + '--store', str(store), 'note'], + capture_output=True, timeout=10) + assert denied.returncode == 2 and marker in denied.stderr + assert not store.exists() + terminal = Terminal(binary, program, store, 'note') + try: + terminal.type(b'hello Pure\x1b[D\x1b[D!\x13') + wait_stored(editor, store, 'note', b'hello Pu!re', 1) + if source: + assert command(*editor, '--store', store, 'show', 'note').stdout == b'hello Pu!re' + terminal.type(b'\x0fsecond\r') + terminal.wait_for(b'Pure | second') + terminal.type('안녕'.encode() + b'\x1b[D' + '하'.encode() + b'\x13') + terminal.wait_for('안하녕'.encode()) + wait_stored(editor, store, 'second', '안하녕'.encode(), 1) + terminal.resize(32, 100) + terminal.wait_for(b'Pure | second') + terminal.type(b'\x0fnote\r') + terminal.wait_for(b'Pure | note') + terminal.type(b'\x0c') + listing = terminal.wait_for(b'Pure | documents') + assert b'note' in listing and b'second' in listing, listing[-1000:] + terminal.type(b'\x1b[B\r') + terminal.wait_for(b'Pure | second') + terminal.type(b'\x0c\x1b[A\r') + terminal.wait_for(b'Pure | note') + terminal.type(b'\x0c') + terminal.wait_for(b'Pure | documents') + (store / 'obj-second').unlink() + terminal.type(b'\x1b[B\r') + terminal.wait_for(b'Document disappeared; refresh the list') + terminal.type(b'\x1b') + terminal.wait_for(b'Pure | note') + terminal.type(b'!') + terminal.type(b'\x0c') + terminal.wait_for(b'Save or reload the draft before browsing') + external = root / 'external' + external.write_bytes(b' remote') + command(*editor, '--store', store, 'edit', 'note', '1', '11', '0', external) + terminal.type(b'\x13') + terminal.wait_for(b'Version conflict') + assert shown(editor, store, 'note') == (b'hello Pu!re remote', '2') + small_peak = memory_kib(terminal.process.pid, 'VmHWM:', portable) + finally: + terminal.close(discard=True) + + multiline = b'first\n\tsecond\nthird' + insert = root / 'multiline-insert' + insert.write_bytes(multiline) + command(*editor, '--store', store, 'edit', 'multiline', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'multiline') + try: + terminal.resize(25, 80) + terminal.wait_for(b'third') + terminal.type(b'!\x13') + wait_stored(editor, store, 'multiline', multiline + b'!', 2) + finally: + terminal.close() + terminal = Terminal(binary, program, store, 'multiline') + terminal.resize(25, 80) + terminal.wait_for(b'third!') + terminal.close() + + rows = [f'line-{index:02}'.encode() for index in range(60)] + insert.write_bytes(b'\n'.join(rows)) + command(*editor, '--store', store, 'edit', 'rows', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'rows') + try: + terminal.type(b'\x1b[A' * 3 + b'X\x13') + rows[56] += b'X' + wait_stored(editor, store, 'rows', b'\n'.join(rows), 2) + terminal.wait_for(b'line-56X') + finally: + terminal.close() + + large = b'Q' * 1_048_576 + insert = root / 'large-insert' + insert.write_bytes(large) + command(*editor, '--store', store, 'edit', 'large', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'large') + try: + terminal.type(b'Z') + terminal.wait_for(b'Z') + terminal.type(b'\x13') + wait_stored(editor, store, 'large', large + b'Z', 2) + large_peak = memory_kib(terminal.process.pid, 'VmHWM:', portable) + finally: + terminal.close() + if rss_limit_kib: + assert large_peak <= rss_limit_kib, (large_peak, rss_limit_kib) + + unicode_body = '안녕'.encode() * 174_762 + insert.write_bytes(unicode_body) + command(*editor, '--store', store, 'edit', 'unicode', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'unicode') + try: + terminal.type(b'K') + terminal.wait_for(b'K') + terminal.type(b'\x1b[HX') + terminal.wait_for(b'X' + '안녕'.encode()) + terminal.type(b'\x13') + wait_stored(editor, store, 'unicode', b'X' + unicode_body + b'K', 2) + finally: + terminal.close() + + listed = root / 'listed-store' + insert.write_bytes(b'X') + for index in range(1024): + command(*editor, '--store', listed, 'edit', f'doc{index:04}', '0', '0', '0', insert) + terminal = Terminal(binary, program, listed, 'doc0000') + try: + terminal.type(b'\x0c') + terminal.wait_for(b'Pure | documents | 1024 keys') + terminal.resize(8, 12) + terminal.wait_for(b'\033[8;1H') + terminal.resize(24, 80) + terminal.wait_for(b'Pure | documents | 1024 keys') + terminal.type(b'\x1b[F\r') + terminal.wait_for(b'Pure | doc1023') + assert shown(editor, listed, 'doc1023') == (b'X', '1') + terminal.type(b'\x0c') + terminal.wait_for(b'Pure | documents | 1024 keys') + terminal.type(b'doc0919') + terminal.wait_for(b'Find: doc0919_') + terminal.type(b'\r') + terminal.wait_for(b'Pure | doc0919') + assert shown(editor, listed, 'doc0919') == (b'X', '1') + terminal.type(b'\x0czz') + terminal.wait_for(b'No matching key') + terminal.type(b'\r') + terminal.wait_for(b'No matching key') + terminal.type(b'\x7f\x7fdoc1023\r') + terminal.wait_for(b'Pure | doc1023') + list_peak = memory_kib(terminal.process.pid, 'VmHWM:', portable) + finally: + terminal.close() + if rss_limit_kib: + assert list_peak <= rss_limit_kib, (list_peak, rss_limit_kib) + + if source: + alternate = root / 'alternate.pure' + definition = program.read_text() + fields = ' start: u32le;\n remove: u32le;' + assert fields in definition + alternate.write_text(definition.replace(fields, + ' remove: u32le;\n start: u32le;')) + alternate_store = root / 'alternate-store' + terminal = Terminal(binary, alternate, alternate_store, 'different_layout') + try: + terminal.type(b'abcdef\x13') + wait_stored(editor, alternate_store, 'different_layout', b'abcdef', 1) + terminal.type(b'\x1b[D\x1b[D\x1b[D!\x13') + wait_stored(editor, alternate_store, 'different_layout', b'abc!def', 2) + finally: + terminal.close() + + terminal = Terminal(binary, program, store, 'note') + terminal.signal_close() + return {'profile': 'native-source-document-terminal-editor' if source else + 'native-fixed-document-terminal-editor', + 'checks': (['source parse and incompatible definitions fail before store creation', + 'source CLI and TUI read the same document', + 'changed source field layout drives TUI input encoding'] if source else []) + + ['cursor insertion and save through terminal', 'open another key', + 'bounded document browser selects keys and protects unsaved draft', + 'UTF-8 codepoint movement, visible redraw and edit', 'resize redraw', + 'external revision conflict preserves draft', + 'stored multiline and tabbed document survives edit and reopen', + 'cached viewport preserves vertical movement across many rows', + 'one MiB document viewport, edit and fresh CLI restore', + 'one MiB UTF-8 viewport and edit before cached anchor', + '1,024 real documents listed, searched, resized and opened' + + (' within RSS gate' if not portable else ''), + 'terminal mode restored after key and signal exit'], + 'small_peak_rss_kib': small_peak, + 'one_mib_peak_rss_kib': large_peak, + 'one_thousand_key_list_peak_rss_kib': list_peak, + 'one_mib_peak_rss_limit_kib': rss_limit_kib, + 'portable': portable, + 'terminal_locale': utf8_locale(), + 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, default=HERE / 'build') + parser.add_argument('--rss-limit-kib', type=int, default=4096, + help='0 skips the release RSS gate for sanitizer builds') + parser.add_argument('--source', action='store_true', help='check the source-loaded TUI') + parser.add_argument('--portable', action='store_true', + help='run all PTY behavior without Linux /proc RSS measurement') + parser.add_argument('--program', type=Path, help='definition for --source (including relocated bundles)') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + result = check(args.build, 0 if args.portable else args.rss_limit_kib, + args.source, args.program, args.portable) + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/check_window.py b/prototypes/connections/check_window.py new file mode 100644 index 0000000..57c96ef --- /dev/null +++ b/prototypes/connections/check_window.py @@ -0,0 +1,553 @@ +#!/usr/bin/env python3 +"""Real GTK/X11 document flow. Run under xvfb-run after `make window editor`.""" +import argparse +import http.client +import json +import os +from pathlib import Path +import re +import select +import struct +import subprocess +import tempfile +import time + +HERE = Path(__file__).resolve().parent +BUILD = HERE / "build" +PROGRAM = HERE / "examples/editor.pure" + + +def command(*args, input=None): + result = subprocess.run(args, input=input, capture_output=True, timeout=15) + if result.returncode: + raise AssertionError((args, result.returncode, result.stdout, result.stderr)) + return result.stdout + + +def xdo(*args): + return command("xdotool", *args).decode().strip() + + +def switch_document(window, key): + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", key) + xdo("key", "Return") + for _ in range(50): + title = xdo("getwindowname", window) + if f"{key} · Pure document" in title: + return + time.sleep(0.05) + raise AssertionError((key, title)) + + +def store_cli(directory, *args): + return subprocess.run( + [str(BUILD / "pure-edit"), "--program", str(PROGRAM), + "--store", str(directory), *args], capture_output=True, timeout=15 + ) + + +def read_document(directory, key, revision): + for _ in range(40): + result = store_cli(directory, "show", key) + if result.returncode == 0: + metadata = json.loads(result.stderr) + if metadata["revision"] == str(revision): + return result.stdout + time.sleep(0.1) + raise AssertionError((key, revision, result.returncode, result.stdout, result.stderr)) + + +def remote_edit(directory, key, revision, offset, inserted): + server = subprocess.Popen( + [str(BUILD / "pure-connections"), "--program", str(PROGRAM), + "--store", str(directory), "--port", "0"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + try: + ready, _, _ = select.select([server.stdout], [], [], 5) + assert ready, "document server did not start" + port = json.loads(server.stdout.readline())["port"] + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) + try: + body = struct.pack(" 5 else '' + category = ('heap' if name == '[heap]' else + 'stack' if name.startswith('[stack') else + 'anonymous' if not name or name.startswith('[anon:') else + 'file' if name.startswith('/') else 'other') + elif line.startswith('Rss:'): + groups[category] += int(line.split()[1]) + return groups + + +def check(check_key_switch=True, switch_rounds=16, diagnose_maps=False, + switch_mode='alternate', graceful_large_close=False, settle_seconds=0, + large_line_width=80): + global PROGRAM + assert os.environ.get("DISPLAY"), "run under xvfb-run or another X11 display" + with tempfile.TemporaryDirectory(prefix="pure-document-window-") as name: + directory = Path(name) + source_definition = PROGRAM.read_bytes() + PROGRAM = directory / "editable.pure" + PROGRAM.write_bytes(source_definition) + app, _ = start_window(directory, "note") + try: + xdo("type", "--clearmodifiers", "--delay", "15", "hello Pure") + xdo("key", "ctrl+s") + assert read_document(directory, "note", 1) == b"hello Pure" + xdo("type", "--clearmodifiers", "!") + if (BUILD / 'pure-connections').exists(): + remote_edit(directory, "note", 1, 10, b" remote") + external_host = 'http' + else: + cli_external_edit(directory, "note", 1, 10, b" remote") + external_host = 'cli' + xdo("key", "ctrl+s") + assert read_document(directory, "note", 2) == b"hello Pure remote" + assert clipboard() == b"hello Pure!", "conflict replaced the local draft" + xdo("key", "ctrl+w") + time.sleep(0.15) + assert app.poll() is None, "unsaved draft closed without confirmation" + xdo("key", "Escape") + time.sleep(0.1) + assert app.poll() is None and clipboard() == b"hello Pure!" + finally: + stop_window(app) + + if check_key_switch: + # One native window must switch among stored objects without dropping + # a draft or installing a non-text object as the active document. + app, window = start_window(directory, "first") + try: + xdo("type", "--clearmodifiers", "alpha") + xdo("key", "ctrl+s") + assert read_document(directory, "first", 1) == b"alpha" + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", "second") + xdo("key", "Return") + assert "second · Pure document" in xdo("getwindowname", window) + xdo("type", "--clearmodifiers", "beta") + xdo("key", "ctrl+s") + assert read_document(directory, "second", 1) == b"beta" + xdo("type", "--clearmodifiers", "!") + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", "third") + xdo("key", "Return") + assert "second · Pure document" in xdo("getwindowname", window) + xdo("mousemove", "--window", window, "120", "150") + xdo("click", "1") + assert clipboard() == b"beta!", "switch discarded the document draft" + xdo("key", "ctrl+s") + assert read_document(directory, "second", 2) == b"beta!" + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", "third") + xdo("key", "Return") + assert "third · Pure document" in xdo("getwindowname", window) + xdo("type", "--clearmodifiers", "gamma") + xdo("key", "ctrl+s") + assert read_document(directory, "third", 1) == b"gamma" + + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", "bad/key") + xdo("key", "Return") + assert "third · Pure document" in xdo("getwindowname", window) + payload = directory / "binary-payload" + payload.write_bytes(b"\x00") + assert store_cli(directory, "edit", "binary", "0", "0", "0", str(payload)).returncode == 0 + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", "binary") + xdo("key", "Return") + assert "third · Pure document" in xdo("getwindowname", window) + assert read_document(directory, "third", 1) == b"gamma" + + xdo("key", "ctrl+shift+d") + xdo("key", "ctrl+End") + xdo("type", "--clearmodifiers", " draft") + xdo("key", "ctrl+o") + xdo("key", "ctrl+a") + xdo("type", "--clearmodifiers", "fourth") + xdo("key", "Return") + assert "third · Pure document" in xdo("getwindowname", window) + xdo("key", "ctrl+shift+d") + xdo("key", "ctrl+shift+d") + assert b" draft" in clipboard(), "switch discarded the definition draft" + finally: + stop_window(app) + + app, _ = start_window(directory, "empty") + try: + xdo("key", "ctrl+s") + assert read_document(directory, "empty", 1) == b"" + finally: + stop_window(app) + app, _ = start_window(directory, "empty") + try: + xdo("key", "ctrl+s") + assert read_document(directory, "empty", 1) == b"" + finally: + stop_window(app) + + insertion = directory / "remote-insert" + insertion.write_bytes("가나다".encode()) + initial = store_cli(directory, "edit", "unicode", "0", "0", "0", str(insertion)) + assert initial.returncode == 0, initial.stderr + app, _ = start_window(directory, "unicode") + try: + assert clipboard() == "가나다".encode() + # xclip forks an owner process which keeps inherited pipe handles. + pasted = subprocess.run(["xclip", "-selection", "clipboard", "-i", + "-t", "UTF8_STRING"], input="각나다".encode(), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5) + assert pasted.returncode == 0 + xdo("key", "ctrl+v") + time.sleep(0.15) + xdo("key", "ctrl+s") + assert read_document(directory, "unicode", 2) == "각나다".encode() + xdo("key", "ctrl+s") + assert read_document(directory, "unicode", 2) == "각나다".encode(), "no-op save changed revision" + finally: + stop_window(app) + app, _ = start_window(directory, "unicode") + try: + assert clipboard() == "각나다".encode(), "new process did not restore the edit" + finally: + stop_window(app) + + # A real 1 MiB document crosses the GTK boundary, but an edit still + # carries only the prepared two u32 fields and one changed byte. + large = ((b"A" * (large_line_width - 1) + b"\n") * + (1_048_576 // large_line_width) + + b"A" * (1_048_576 % large_line_width)) + assert len(large) == 1_048_576 + insertion.write_bytes(large) + initial = store_cli(directory, "edit", "large", "0", "0", "0", str(insertion)) + assert initial.returncode == 0, initial.stderr + if check_key_switch: + other_large = b"B" + large[1:] + other_revision = 1 + insertion.write_bytes(other_large) + assert store_cli(directory, "edit", "large_other", "0", "0", "0", + str(insertion)).returncode == 0 + switch_rss_samples = [] + switch_map_samples = [] + app, large_window = start_window(directory, "large") + try: + time.sleep(0.25) + resident = rss_kib(app.pid) + xdo("key", "ctrl+End") + xdo("key", "BackSpace") + xdo("type", "--clearmodifiers", "Z") + xdo("key", "ctrl+s") + stored = read_document(directory, "large", 2) + assert stored == large[:-1] + b"Z" + if check_key_switch: + if switch_mode == 'same-body': + insertion.write_bytes(stored) + assert store_cli(directory, "edit", "large_other", "1", "0", + str(len(other_large)), str(insertion)).returncode == 0 + other_large = stored + other_revision = 2 + for step in range(switch_rounds): + key = "large" if switch_mode == 'same' else ( + "large_other" if step % 2 == 0 else "large") + switch_document(large_window, key) + if (step + 1) % 8 == 0: + switch_rss_samples.append(rss_kib(app.pid)) + if diagnose_maps: + switch_map_samples.append(rss_breakdown_kib(app.pid)) + assert read_document(directory, "large", 2) == stored + assert read_document(directory, "large_other", other_revision) == other_large + if settle_seconds: + time.sleep(settle_seconds) + settled_rss = rss_kib(app.pid) + settled_maps = rss_breakdown_kib(app.pid) if diagnose_maps else None + if graceful_large_close: + xdo("key", "ctrl+w") + _, error = app.communicate(timeout=15) + assert app.returncode == 0, (app.returncode, error[-4000:]) + finally: + large_stderr = stop_window(app) + + # Author the same running document app in the native window. Failed + # preparation and an external source conflict must preserve the draft. + changed_definition = source_definition.replace(b"reply(saved, edit.insert);", + b"reply(saved);") + assert changed_definition != source_definition + app, window = start_window(directory, "authored") + try: + xdo("key", "ctrl+shift+d") + time.sleep(0.25) + pasted = subprocess.run(["xclip", "-selection", "clipboard", "-i", + "-t", "UTF8_STRING"], input=changed_definition, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5) + assert pasted.returncode == 0 + xdo("key", "ctrl+a") + xdo("key", "ctrl+v") + xdo("key", "ctrl+shift+s") + for _ in range(30): + if PROGRAM.read_bytes() == changed_definition: + break + time.sleep(0.1) + assert PROGRAM.read_bytes() == changed_definition, "definition was not saved" + assert "definition 2" in xdo("getwindowname", window) + xdo("key", "ctrl+shift+d") + xdo("type", "--clearmodifiers", "Z") + xdo("key", "ctrl+s") + assert read_document(directory, "authored", 1) == b"Z" + assert "output 0 B" in xdo("getwindowname", window), "old reply plan stayed active" + + xdo("key", "ctrl+shift+d") + cannot_save = changed_definition + b"\n// source write must fail\n" + pasted = subprocess.run(["xclip", "-selection", "clipboard", "-i", + "-t", "UTF8_STRING"], input=cannot_save, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5) + assert pasted.returncode == 0 + xdo("key", "ctrl+a") + xdo("key", "ctrl+v") + prior_mode = directory.stat().st_mode & 0o777 + os.chmod(directory, 0o500) + try: + xdo("key", "ctrl+shift+s") + finally: + os.chmod(directory, prior_mode) + assert PROGRAM.read_bytes() == changed_definition, "failed source save changed the file" + assert "definition 2" in xdo("getwindowname", window) + assert clipboard() == cannot_save, "failed source save dropped the draft" + + partial_read = source_definition.replace( + b"reply(document, document.body);", + b"reply(document, range(document.body, 0, 1));") + assert partial_read != source_definition + pasted = subprocess.run(["xclip", "-selection", "clipboard", "-i", + "-t", "UTF8_STRING"], input=partial_read, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5) + assert pasted.returncode == 0 + xdo("key", "ctrl+a") + xdo("key", "ctrl+v") + xdo("key", "ctrl+shift+s") + assert PROGRAM.read_bytes() == changed_definition, "partial read made document editing unsafe" + assert "definition 2" in xdo("getwindowname", window) + + invalid = b"not a Pure definition\n" + pasted = subprocess.run(["xclip", "-selection", "clipboard", "-i", + "-t", "UTF8_STRING"], input=invalid, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5) + assert pasted.returncode == 0 + xdo("key", "ctrl+a") + xdo("key", "ctrl+v") + xdo("key", "ctrl+shift+s") + assert PROGRAM.read_bytes() == changed_definition + assert "definition 2" in xdo("getwindowname", window) + xdo("key", "ctrl+a") + xdo("key", "ctrl+c") + assert clipboard() == invalid, "invalid definition draft was discarded" + + local_draft = changed_definition + b"\n// local draft\n" + external = source_definition + b"\n// external change\n" + pasted = subprocess.run(["xclip", "-selection", "clipboard", "-i", + "-t", "UTF8_STRING"], input=local_draft, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5) + assert pasted.returncode == 0 + xdo("key", "ctrl+a") + xdo("key", "ctrl+v") + PROGRAM.write_bytes(external) + xdo("key", "ctrl+shift+s") + assert PROGRAM.read_bytes() == external, "external definition was overwritten" + assert "definition 2" in xdo("getwindowname", window) + xdo("key", "ctrl+w") + time.sleep(0.15) + assert app.poll() is None, "unsaved definition draft closed without confirmation" + xdo("key", "Escape") + time.sleep(0.1) + assert app.poll() is None + xdo("mousemove", "--window", window, "680", "326") + xdo("click", "1") # Explicitly discard the source draft and reload disk. + xdo("key", "ctrl+shift+s") + assert PROGRAM.read_bytes() == external + assert "definition 3" in xdo("getwindowname", window) + xdo("key", "ctrl+shift+d") + xdo("type", "--clearmodifiers", "Y") + xdo("key", "ctrl+s") + assert len(read_document(directory, "authored", 2)) == 2 + assert "output 1 B" in xdo("getwindowname", window), "reloaded definition did not run" + finally: + stop_window(app) + + app, window = start_window(directory, "author-restored") + try: + xdo("type", "--clearmodifiers", "R") + xdo("key", "ctrl+s") + assert read_document(directory, "author-restored", 1) == b"R" + assert "output 1 B" in xdo("getwindowname", window), "saved definition did not restore" + finally: + stop_window(app) + + result = {"gtk_cli_write": "pass", f"{external_host}_conflict_preserves_draft": "pass", + "unsaved_close_guard": "pass", + "empty_document_restore": "pass", + "utf8_boundary_and_restart": "pass", "unchanged_save_keeps_revision": "pass", + "one_mib_document_one_byte_edit": "pass", "large_window_rss_kib": resident, + "native_definition_apply_and_running_output": "pass", + "definition_restores_in_new_window": "pass", + "partial_read_definition_rejected": "pass", + "source_write_failure_preserves_active_definition": "pass", + "invalid_and_external_definition_preserve_draft": "pass", + "reloaded_definition_installs_without_rewriting_source": "pass", + "profile": "optional-native-document-window"} + if check_key_switch: + result["multi_document_switch_preserves_draft"] = "pass" + result["large_switch_rss_samples_kib"] = switch_rss_samples + result["large_switch_mode"] = switch_mode + result["large_line_width"] = large_line_width + if settle_seconds: + result["large_switch_settled_rss_kib"] = settled_rss + if diagnose_maps: + result["large_switch_settled_map_kib"] = settled_maps + if diagnose_maps: + result["large_switch_map_samples_kib"] = switch_map_samples + allocations = re.findall( + rb'PURE_MALLOC key=([^ ]+) arena=(\d+) used=(\d+) free=(\d+) mapped=(\d+)', + large_stderr) + if allocations: + result['large_malloc_samples_bytes'] = [ + {'key': key.decode(), 'arena': int(arena), 'used': int(used), + 'free': int(free), 'mapped': int(mapped)} + for key, arena, used, free, mapped in allocations] + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path) + parser.add_argument("--build", type=Path, default=BUILD) + parser.add_argument("--program", type=Path, default=PROGRAM) + parser.add_argument("--legacy-window", action="store_true", + help="check an archived window built before document switching existed") + parser.add_argument("--switch-rounds", type=int, default=16, + help="number of alternating 1 MiB document opens, in groups of eight") + parser.add_argument("--diagnose-maps", action="store_true", + help="record RSS by Linux mapping category after each eight switches") + parser.add_argument("--switch-mode", choices=('alternate', 'same', 'same-body'), + default='alternate', + help="alternate two different or identical documents, or reopen one") + parser.add_argument("--graceful-large-close", action="store_true", + help="exit the large-text window normally so leak detectors can run") + parser.add_argument("--settle-seconds", type=float, default=0, + help="wait after the final switch, then sample RSS again") + parser.add_argument("--large-line-width", type=int, default=80, + help="bytes per line in the 1 MiB fixture (default: 80)") + args = parser.parse_args() + if args.switch_rounds < 8 or args.switch_rounds > 128 or args.switch_rounds % 8: + parser.error('--switch-rounds must be a multiple of eight between 8 and 128') + if args.settle_seconds < 0 or args.settle_seconds > 30: + parser.error('--settle-seconds must be between zero and 30') + if args.large_line_width < 2 or args.large_line_width > 1_048_576: + parser.error('--large-line-width must be between 2 and 1048576') + BUILD = args.build.resolve() + PROGRAM = args.program.resolve() + result = check(not args.legacy_window, args.switch_rounds, + args.diagnose_maps, args.switch_mode, args.graceful_large_close, + args.settle_seconds, args.large_line_width) + text = json.dumps(result, sort_keys=True) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text) + print(text, end="") diff --git a/prototypes/connections/check_write_cost.py b/prototypes/connections/check_write_cost.py new file mode 100644 index 0000000..4dcfe03 --- /dev/null +++ b/prototypes/connections/check_write_cost.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Trace one real document replacement through each selected storage binary.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tempfile + +HERE = Path(__file__).resolve().parent +WRITE = re.compile(r'\bwrite\(\d+<([^>]+)>, .*?, (\d+)\) = (\d+)$') + + +def command(*args): + result = subprocess.run([str(arg) for arg in args], capture_output=True, timeout=20) + if result.returncode: + raise AssertionError((result.args, result.returncode, result.stderr[-1000:])) + return result + + +def inspect(binary, root, key, initial, patch, offset): + program = HERE / 'examples/editor.pure' + store = root / (binary.parent.name + '-' + key) + base = [binary, '--program', program, '--store', store] + command(*base, 'edit', key, 0, 0, 0, initial) + trace = root / (binary.parent.name + '-' + key + '.trace') + command('strace', '-qq', '-e', 'trace=write,pwrite64,writev,pwritev,fsync', + '-yy', '-o', trace, *base, 'edit', key, 1, offset, 1, patch) + expected = initial.read_bytes()[:offset] + patch.read_bytes() + initial.read_bytes()[offset + 1:] + assert command(*base, 'show', key).stdout == expected + writes = [] + for line in trace.read_text().splitlines(): + if '/.pending-' not in line and '/dat-' + key + '-' not in line: + continue + if not any(word in line for word in ('write(', 'pwrite64(', 'writev(', 'pwritev(')): + continue + match = WRITE.search(line) + if not match: + raise AssertionError(f'unaccounted object write: {line}') + requested, completed = int(match[2]), int(match[3]) + if requested != completed: + raise AssertionError(f'partial object write: {line}') + writes.append(completed) + return {'object_write_syscall_bytes': sum(writes), 'object_write_calls': len(writes), + 'object_files': {item.name: item.stat().st_size for item in store.iterdir() + if item.name.startswith(('obj-', 'dat-'))}, + 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--flat', type=Path, required=True) + parser.add_argument('--paged', '--candidate', dest='candidate', type=Path, required=True) + parser.add_argument('--candidate-mode', choices=('paged', 'hybrid'), default='paged') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + with tempfile.TemporaryDirectory(prefix='pure-write-cost-') as name: + root = Path(name) + initial, tiny, patch = root / 'initial', root / 'tiny', root / 'patch' + initial.write_bytes(b'A' * 1_048_576) + tiny.write_bytes(b'A') + patch.write_bytes(b'Z') + large_flat = inspect(args.flat.resolve(), root, 'large', initial, patch, 1_048_575) + large_paged = inspect(args.candidate.resolve(), root, 'large', initial, patch, 1_048_575) + tiny_flat = inspect(args.flat.resolve(), root, 'tiny', tiny, patch, 0) + tiny_paged = inspect(args.candidate.resolve(), root, 'tiny', tiny, patch, 0) + if [large_flat['object_write_syscall_bytes'], large_paged['object_write_syscall_bytes'], + tiny_flat['object_write_syscall_bytes'], tiny_paged['object_write_syscall_bytes']] != \ + [1_048_600, 16_944, 25, 25 if args.candidate_mode == 'hybrid' else 16_440]: + raise AssertionError((large_flat, large_paged, tiny_flat, tiny_paged)) + result = {'profile': 'linux-aarch64-16k-page-document-replacement', + 'candidate_mode': args.candidate_mode, + 'workloads': ['replace last byte of 1 MiB document', 'replace one-byte document'], + 'unit': 'successful user-space write syscall bytes targeting object files', + 'excludes': 'physical device writes, filesystem journal, metadata and directory sync cost', + 'one_mib': {'flat': large_flat, args.candidate_mode: large_paged, + f'flat_to_{args.candidate_mode}_ratio': large_flat['object_write_syscall_bytes'] / + large_paged['object_write_syscall_bytes']}, + 'one_byte': {'flat': tiny_flat, args.candidate_mode: tiny_paged, + f'{args.candidate_mode}_to_flat_ratio': tiny_paged['object_write_syscall_bytes'] / + tiny_flat['object_write_syscall_bytes']}} + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/compile-program.c b/prototypes/connections/compile-program.c new file mode 100644 index 0000000..7e291f3 --- /dev/null +++ b/prototypes/connections/compile-program.c @@ -0,0 +1,116 @@ +#include "program.h" +#include +#include +#include +#include + +/* Build-time preparation. The generated initializer is compiled with the + * target, so the run-only host contains neither this tool nor the parser. */ +static void region(FILE *out, PureRegion value) { + fprintf(out, "{.offset=%zu,.length=%zu,.to_end=%s}", value.offset, + value.length, value.to_end ? "true" : "false"); +} + +static void index_value(FILE *out, PureIndex value) { + fprintf(out, "{.value=%zu,.from_u32le=%s}", value.value, + value.from_u32le ? "true" : "false"); +} + +static void emit(FILE *out, const PureProgram *p) { + fputs("/* Generated from a validated .pure definition. Do not edit. */\n" + "static const PureProgram pure_embedded_program = {\n.read=", out); + region(out, p->read); + fputs(",\n.write=", out); + region(out, p->write); + fputs(",\n.reply=", out); + region(out, p->reply); + fprintf(out, ",\n.read_required=%zu,.input_required=%zu,\n" + ".view_definitions=%zu,.range_definitions=%zu,\n" + ".write_step=%zu,.reply_step=%zu,.input_steps=%zu,\n", + p->read_required, p->input_required, p->view_definitions, + p->range_definitions, p->write_step, p->reply_step, p->input_steps); + if (p->input_steps) fputs(".steps={\n", out); + for (size_t i = 0; i < p->input_steps; ++i) { + const PureRangeStep *step = &p->steps[i]; + fputs("{.source=", out); + region(out, step->source); + fprintf(out, ",.source_step=%zu,.offset=", step->source_step); + index_value(out, step->offset); + fputs(",.length=", out); + index_value(out, step->length); + fputs("},\n", out); + } + if (p->input_steps) fputs("},\n", out); + fprintf(out, ".reply_input=%s,.splice=%s,\n.splice_offset=", + p->reply_input ? "true" : "false", p->splice ? "true" : "false"); + index_value(out, p->splice_offset); + fputs(",.splice_remove=", out); + index_value(out, p->splice_remove); + fprintf(out, ",.splice_limit=%u\n};\n", p->splice_limit); +} + +static bool same_file(const char *left_path, const char *right_path) { + FILE *left = fopen(left_path, "rb"); + FILE *right = fopen(right_path, "rb"); + if (!left || !right) { + if (left) fclose(left); + if (right) fclose(right); + return false; + } + bool same = true; + for (;;) { + int a = fgetc(left), b = fgetc(right); + if (a != b) { same = false; break; } + if (a == EOF) break; + } + same = same && !ferror(left) && !ferror(right); + int left_status = fclose(left), right_status = fclose(right); + if (left_status || right_status) same = false; + return same; +} + +int main(int argc, char **argv) { + if (argc != 3) { + fputs("usage: pure-compile SOURCE.pure OUTPUT.h\n", stderr); + return 2; + } + struct stat source_info, output_info; + if (!stat(argv[1], &source_info) && !stat(argv[2], &output_info) && + source_info.st_dev == output_info.st_dev && + source_info.st_ino == output_info.st_ino) { + fputs("source and output must be different files\n", stderr); + return 2; + } + PureProgram program; + char error[160]; + if (!pure_program_load(argv[1], &program, error, sizeof(error))) { + fprintf(stderr, "Program: %s\n", error); + return 2; + } + size_t path_size = strlen(argv[2]) + 5; + char *temporary = malloc(path_size); + if (!temporary) { fputs("output path allocation failed\n", stderr); return 2; } + snprintf(temporary, path_size, "%s.tmp", argv[2]); + FILE *out = fopen(temporary, "wb"); + if (!out) { perror("output"); free(temporary); return 2; } + emit(out, &program); + if (fclose(out)) { + perror("output"); + remove(temporary); + free(temporary); + return 2; + } + if (same_file(temporary, argv[2])) { + remove(temporary); + free(temporary); + return 0; + } + if (rename(temporary, argv[2])) { + perror("output"); + remove(temporary); + free(temporary); + return 2; + } + free(temporary); + return 0; +} diff --git a/prototypes/connections/core-budget.json b/prototypes/connections/core-budget.json new file mode 100644 index 0000000..ebe4675 --- /dev/null +++ b/prototypes/connections/core-budget.json @@ -0,0 +1,15 @@ +{ + "profile": "opaque-definition-execution-core", + "max_text_bytes": 4864, + "max_data_bytes": 0, + "max_bss_bytes": 0, + "layout_max_bytes": { + "domain_bytes": 120, + "work_slot_bytes": 216, + "ticket_bytes": 24, + "pointer_bytes": 8 + }, + "archive_members": ["runtime.o"], + "external_symbols_allowed": ["memcpy", "memmove", "memset", "strlen"], + "scope": "Opaque-definition execution, resource credits, completion and loans only. Document view/splice evaluation is linked into the selected adapter. Without opt-in PURE_RUNTIME_METRICS, validated with GNU tools on Linux AArch64. Not total RAM, process RSS, stack, linked libraries, parser, storage, network or UI. This ceiling is a regression guard for this profile, not a universal runtime size promise." +} diff --git a/prototypes/connections/core-layout.c b/prototypes/connections/core-layout.c new file mode 100644 index 0000000..f037c76 --- /dev/null +++ b/prototypes/connections/core-layout.c @@ -0,0 +1,14 @@ +#include "runtime.h" +#include + +/* Host-side measurement tool; never linked into libpure-execution.a. */ +int main(void) { + printf("{\"domain_bytes\":%zu,\"work_slot_bytes\":%zu,\"ticket_bytes\":%zu," + "\"pointer_bytes\":%zu", + sizeof(PureRuntime), sizeof(PureWork), sizeof(PureTicket), sizeof(void *)); +#ifdef PURE_ASYNC_EFFECTS + printf(",\"effect_bytes\":%zu", sizeof(PureEffect)); +#endif + puts("}"); + return 0; +} diff --git a/prototypes/connections/document-peer.c b/prototypes/connections/document-peer.c new file mode 100644 index 0000000..9cbc64f --- /dev/null +++ b/prototypes/connections/document-peer.c @@ -0,0 +1,330 @@ +#define _POSIX_C_SOURCE 200809L +/* Selected document peer: same prepared program, store and execution core as + * pure-edit. TLS and the wire protocol remain outside libpure-execution.a. */ +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include "../common/tls-link.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { REQUEST_BYTES = 14, REPLY_BYTES = 13, HELLO_BYTES = 40, + OP_READ = 1, OP_WRITE = 2, WANT_BODY = 0x80 }; +static const unsigned char alpn[] = {10, 'p','u','r','e','-','d','o','c','/','1'}; +static volatile sig_atomic_t stopping; +static void signal_stop(int signum) { (void)signum; stopping = 1; } + +static uint32_t get32(const unsigned char *p) { + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; +} +static uint64_t get64(const unsigned char *p) { + uint64_t value = 0; + for (unsigned i = 0; i < 8; ++i) value = (value << 8) | p[i]; + return value; +} +static void put32(unsigned char *p, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) p[i] = (unsigned char)(value >> (24 - 8 * i)); +} +static void put64(unsigned char *p, uint64_t value) { + for (unsigned i = 0; i < 8; ++i) p[i] = (unsigned char)(value >> (56 - 8 * i)); +} +#ifndef PURE_PEER_SERVER_ONLY +static void put32le(unsigned char *p, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) p[i] = (unsigned char)(value >> (8 * i)); +} +#endif +static bool number(const char *text, uint64_t limit, uint64_t *out) { + if (!text || !*text) return false; + uint64_t value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > limit || value > (limit - digit) / 10) return false; + value = value * 10 + digit; + } + *out = value; + return true; +} +static bool load_program(const char *path, PureProgram *program, unsigned char digest[32]) { + FILE *file = fopen(path, "rb"); + if (!file) return false; + char source[PURE_PROGRAM_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool bad = ferror(file) != 0; + if (fclose(file)) bad = true; + char error[160]; + if (bad || !pure_program_parse(source, length, program, error, sizeof(error))) { + if (!bad) fprintf(stderr, "Program: %s\n", error); + return false; + } + return SHA256((const unsigned char *)source, length, digest) != NULL; +} +#ifndef PURE_PEER_SERVER_ONLY +static bool edit_layout(const PureProgram *p, size_t *prefix) { + if (!p->splice || !p->write.to_end || p->write_step || p->input_steps || + !p->splice_offset.from_u32le || !p->splice_remove.from_u32le) return false; + size_t start = p->splice_offset.value, remove = p->splice_remove.value; + *prefix = p->write.offset; + /* The convenience command is deliberately the eight-byte editor profile. + * Other authored layouts use apply, with no layout-specific wire fields. */ + return *prefix == 8 && p->input_required <= 8 && + ((start == 0 && remove == 4) || (start == 4 && remove == 0)); +} +#endif +static bool write_hello(PureLink *link, const unsigned char digest[32], uint64_t deadline) { + unsigned char hello[HELLO_BYTES] = {'P','D','O','C',1,0,0,0}; + memcpy(hello + 8, digest, 32); + return pure_link_write(link, hello, sizeof(hello), deadline); +} +#ifndef PURE_PEER_SERVER_ONLY +static bool read_hello(PureLink *link, const unsigned char digest[32], uint64_t deadline) { + unsigned char hello[HELLO_BYTES]; + const unsigned char header[] = {'P','D','O','C',1,0,0,0}; + return pure_link_read(link, hello, sizeof(hello), deadline) == 1 && + !memcmp(hello, header, sizeof(header)) && !memcmp(hello + 8, digest, 32); +} +#endif +/* The header always reports the logical output length. The requester chooses + * whether that borrowed output must cross this boundary at all. */ +static bool send_result(PureLink *link, PureOutcome outcome, bool want_body, uint64_t deadline) { + if (outcome.status > PURE_ALLOCATION || outcome.revision < 0 || outcome.body.size > PURE_OBJECT_LIMIT || + (outcome.body.size && !outcome.body.data)) return false; + unsigned char header[REPLY_BYTES]; + header[0] = (unsigned char)outcome.status; + put64(header + 1, (uint64_t)outcome.revision); + put32(header + 9, (uint32_t)outcome.body.size); + return pure_link_write(link, header, sizeof(header), deadline) && + (!want_body || pure_link_write(link, outcome.body.data, outcome.body.size, deadline)); +} +static bool serve_request(PureLink *link, PureRuntime *runtime, int *end) { + uint64_t deadline = pure_link_now_ms() + PURE_LINK_DEADLINE_MS; + unsigned char header[REQUEST_BYTES]; + int got = pure_link_read(link, header, sizeof(header), deadline); + if (!got) { *end = 1; return true; } + if (got != 1) return false; + unsigned op = header[0] & ~WANT_BODY, key_size = header[1]; + bool want_body = (header[0] & WANT_BODY) != 0; + uint64_t expected = get64(header + 2); + uint32_t body_size = get32(header + 10); + if ((op != OP_READ && op != OP_WRITE) || !key_size || key_size > 64 || + expected > INT64_MAX || body_size > PURE_OBJECT_LIMIT || + (op == OP_READ && (expected || body_size))) return false; + char key[65]; + if (pure_link_read(link, key, key_size, deadline) != 1) return false; + if (memchr(key, 0, key_size)) return false; + key[key_size] = 0; + PureTicket ticket; + PureResult accepted = pure_runtime_accept(runtime, op == OP_READ ? PURE_INPUT_READ : PURE_INPUT_WRITE, + key, (int64_t)expected, body_size, &ticket); + if (accepted != PURE_OK) { + /* The body is intentionally not consumed after failed admission. */ + PureOutcome error = {.status = accepted}; + (void)send_result(link, error, want_body, deadline); + return false; + } + if (op == OP_WRITE) { + size_t size = 0; + unsigned char *ingress = pure_runtime_ingress(ticket, &size); + if (size != body_size || (size && (!ingress || pure_link_read(link, ingress, size, deadline) != 1)) || + !pure_runtime_ready(ticket)) { + (void)pure_runtime_release(ticket); + return false; + } + } + PureOutcome outcome; + while (!pure_runtime_result(ticket, &outcome)) { + if (!pure_runtime_step(runtime)) { (void)pure_runtime_release(ticket); return false; } + } + bool sent = send_result(link, outcome, want_body, deadline); + (void)pure_runtime_release(ticket); + return sent; +} +static bool serve_connection(PureLink *link, PureRuntime *runtime, const unsigned char digest[32]) { + if (!write_hello(link, digest, pure_link_now_ms() + PURE_LINK_DEADLINE_MS)) return false; + int end = 0; + while (!end) if (!serve_request(link, runtime, &end)) return false; + return true; +} +static int serve(const char *bind_address, const char *port, const char *program_path, + const char *store_path, const char *ca, const char *cert, + const char *key, const char *client_name, unsigned max_connections) { + if (!pure_link_port_number(port, true) || !client_name || !*client_name) return 2; + PureProgram program; + unsigned char digest[32]; + if (!load_program(program_path, &program, digest) || pure_store_init(store_path) != PURE_OK) return 2; + SSL_CTX *ctx = pure_link_context(true, ca, cert, key, alpn); + if (!ctx) { ERR_print_errors_fp(stderr); return 2; } + PureRuntime runtime; + PureWork work[1]; + if (!pure_runtime_init(&runtime, work, 1, &program, pure_store_host(store_path), + 4u * 1024u * 1024u)) { SSL_CTX_free(ctx); return 2; } + int listener = pure_link_listen_at(bind_address, port); + if (listener < 0) { perror("listen"); SSL_CTX_free(ctx); return 2; } + struct sockaddr_storage address; + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) abort(); + unsigned actual_port = address.ss_family == AF_INET + ? ntohs(((struct sockaddr_in *)&address)->sin_port) + : ntohs(((struct sockaddr_in6 *)&address)->sin6_port); + printf("READY %u\n", actual_port); fflush(stdout); + signal(SIGTERM, signal_stop); signal(SIGINT, signal_stop); + unsigned accepted = 0; + while (!stopping && (!max_connections || accepted < max_connections)) { + struct pollfd p = {.fd = listener, .events = POLLIN}; + int result = poll(&p, 1, 1000); + if (result < 0) { if (errno == EINTR) continue; break; } + if (!result) continue; + int fd = accept(listener, NULL, NULL); + if (fd < 0) { if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue; break; } + ++accepted; + PureLink link = {.fd = fd}; + bool healthy = pure_link_nonblocking(fd) && + pure_link_handshake(&link, ctx, true, client_name, alpn); + if (healthy) healthy = serve_connection(&link, &runtime, digest); + pure_link_close(&link, healthy); + } + close(listener); + pure_runtime_stop(&runtime); + int status = runtime.active || runtime.retained ? 1 : 0; + SSL_CTX_free(ctx); + return status; +} +#ifndef PURE_PEER_SERVER_ONLY +static int receive_result(PureLink *link, bool show, uint64_t deadline) { + unsigned char header[REPLY_BYTES]; + if (pure_link_read(link, header, sizeof(header), deadline) != 1 || + header[0] > PURE_ALLOCATION || get64(header + 1) > INT64_MAX || + get32(header + 9) > PURE_OBJECT_LIMIT) return -1; + PureResult result = (PureResult)header[0]; + int64_t revision = (int64_t)get64(header + 1); + uint32_t length = get32(header + 9); + if (result != PURE_OK && length) return -1; + unsigned char buffer[4096]; + size_t left = show ? length : 0; + int failed = 0; + while (left) { + size_t count = left < sizeof(buffer) ? left : sizeof(buffer); + if (pure_link_read(link, buffer, count, deadline) != 1) return -1; + if (show && result == PURE_OK && fwrite(buffer, 1, count, stdout) != count) failed = 1; + left -= count; + } + if (result != PURE_OK) { + fprintf(stderr, "{\"status\":%u,\"revision\":\"%" PRId64 "\"}\n", result, revision); + return result == PURE_CONFLICT ? 3 : 2; + } + if (show) fprintf(stderr, "{\"revision\":\"%" PRId64 "\",\"bytes\":%u}\n", revision, length); + else if (printf("{\"revision\":\"%" PRId64 "\",\"reply_bytes\":%u}\n", revision, length) < 0) failed = 1; + if (fflush(stdout)) failed = 1; + return failed ? 2 : 0; +} +static int call(const char *host, const char *port, const char *server_name, + const char *program_path, const char *ca, const char *cert, + const char *key, int argc, char **argv) { + if (!pure_link_port_number(port, false) || !server_name || !*server_name || argc < 2) return 2; + bool show = !strcmp(argv[0], "show"), edit = !strcmp(argv[0], "edit"); + if ((!show && !edit && strcmp(argv[0], "apply")) || argc != (show ? 2 : edit ? 6 : 4)) return 2; + PureProgram program; + unsigned char digest[32]; + if (!load_program(program_path, &program, digest)) return 2; + size_t prefix = 0; + if (edit && !edit_layout(&program, &prefix)) return 2; + uint64_t expected = 0, offset = 0, remove = 0; + if (!show && !number(argv[2], INT64_MAX, &expected)) return 2; + if (edit && (!number(argv[3], UINT32_MAX, &offset) || + !number(argv[4], UINT32_MAX, &remove))) return 2; + const char *document = argv[1]; + size_t key_size = strlen(document); + if (!key_size || key_size > 64) return 2; + FILE *file = NULL; + size_t body_size = 0; + if (!show) { + file = fopen(argv[edit ? 5 : 3], "rb"); + if (!file) { perror("input"); return 2; } + long bytes; + if (fseek(file, 0, SEEK_END) || (bytes = ftell(file)) < 0 || + (uintmax_t)bytes > PURE_OBJECT_LIMIT - prefix || fseek(file, 0, SEEK_SET)) { + fclose(file); return 2; + } + body_size = prefix + (size_t)bytes; + } + SSL_CTX *ctx = pure_link_context(false, ca, cert, key, alpn); + if (!ctx) { if (file) fclose(file); ERR_print_errors_fp(stderr); return 2; } + PureLink link = {.fd = pure_link_connect_to(host, port)}; + bool ready = link.fd >= 0 && pure_link_handshake(&link, ctx, false, server_name, alpn); + bool matched = ready && read_hello(&link, digest, pure_link_now_ms() + PURE_LINK_DEADLINE_MS); + if (!matched) { + fputs(ready ? "peer application definition mismatch\n" : + "peer authentication or connection failed\n", stderr); + pure_link_close(&link, false); + SSL_CTX_free(ctx); + if (file) fclose(file); + return 2; + } + int status = 2; + { + uint64_t deadline = pure_link_now_ms() + PURE_LINK_DEADLINE_MS; + unsigned char header[REQUEST_BYTES] = {0}; + header[0] = (show ? OP_READ : OP_WRITE) | (show ? WANT_BODY : 0); + header[1] = (unsigned char)key_size; + put64(header + 2, expected); + put32(header + 10, (uint32_t)body_size); + ready = pure_link_write(&link, header, sizeof(header), deadline) && + pure_link_write(&link, document, key_size, deadline); + if (ready && prefix) { + unsigned char fields[8] = {0}; + put32le(fields + program.splice_offset.value, (uint32_t)offset); + put32le(fields + program.splice_remove.value, (uint32_t)remove); + ready = pure_link_write(&link, fields, prefix, deadline); + } + unsigned char buffer[4096]; + size_t left = body_size - prefix; + while (ready && left) { + size_t count = left < sizeof(buffer) ? left : sizeof(buffer); + if (fread(buffer, 1, count, file) != count) { ready = false; break; } + ready = pure_link_write(&link, buffer, count, deadline); + left -= count; + } + if (file && (fgetc(file) != EOF || ferror(file))) ready = false; + if (ready) { + status = receive_result(&link, show, deadline); + if (status < 0) { status = 2; ready = false; } + } + } + if (!ready) fputs("peer failed or timed out; an interrupted write may have committed; query before retry\n", stderr); + pure_link_close(&link, ready); + SSL_CTX_free(ctx); + if (file && fclose(file)) status = 2; + return status; +} +#endif +int main(int argc, char **argv) { + signal(SIGPIPE, SIG_IGN); +#ifndef PURE_PEER_SERVER_ONLY + if (argc >= 11 && !strcmp(argv[1], "call")) + return call(argv[2], argv[3], argv[4], argv[5], argv[6], argv[7], argv[8], argc - 9, argv + 9); +#endif + if ((argc == 10 || argc == 11) && !strcmp(argv[1], "serve")) { + uint64_t limit = 0; + if (argc == 11 && (!number(argv[10], UINT32_MAX, &limit) || !limit)) goto usage; + return serve(argv[2], argv[3], argv[4], argv[5], argv[6], argv[7], argv[8], argv[9], + (unsigned)limit); + } +usage: + fputs("pure-document-peer serve BIND_IP PORT PROGRAM STORE_DIR CA.pem SERVER_CERT.pem SERVER_KEY.pem CLIENT_DNS [MAX_CONNECTIONS]\n", stderr); +#ifndef PURE_PEER_SERVER_ONLY + fputs("pure-document-peer call HOST PORT SERVER_DNS PROGRAM CA.pem CLIENT_CERT.pem CLIENT_KEY.pem show KEY\n" + "pure-document-peer call HOST PORT SERVER_DNS PROGRAM CA.pem CLIENT_CERT.pem CLIENT_KEY.pem edit KEY REV OFFSET REMOVE INSERT_FILE\n" + "pure-document-peer call HOST PORT SERVER_DNS PROGRAM CA.pem CLIENT_CERT.pem CLIENT_KEY.pem apply KEY REV INPUT_FILE\n", stderr); +#endif + return 2; +} diff --git a/prototypes/connections/edit-tui.c b/prototypes/connections/edit-tui.c new file mode 100644 index 0000000..fbd73c1 --- /dev/null +++ b/prototypes/connections/edit-tui.c @@ -0,0 +1,945 @@ +#define _XOPEN_SOURCE 700 +/* O_NOFOLLOW is hidden by Darwin's strict X/Open namespace. */ +#ifdef __APPLE__ +#define _DARWIN_C_SOURCE +#endif +/* Optional terminal document editor. The program and store effect are the + * same ones used by the CLI and HTTP hosts; terminal state stays here. */ +#include "runtime.h" +#include "store.h" +#include "store-port.h" +#ifdef PURE_EMBEDDED_PROGRAM +#include "embedded-plan.h" +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + char name[65]; +} DocumentKey; + +typedef struct { + PureRuntime runtime; + PureWork work[2]; + const PureProgram *program; + const char *store; + char key[65], prompt[65], message[160]; + unsigned char *text; + size_t capacity, gap_start, gap_end, scroll_row, prefix, prompt_size; + /* Logical byte position and its visual coordinate in anchor_columns. */ + size_t anchor_position, anchor_row, anchor_column, anchor_columns; + DocumentKey *documents; + size_t document_count, document_selected, document_scroll; + int64_t revision; + bool exists, dirty, prompt_open, list_open, confirm_quit, confirm_reload, anchor_valid; + struct termios original_term; +} Editor; + +static volatile sig_atomic_t stopping, resized, wake_write = -1; + +static void wake_signal(int signal_number) { + int saved_errno = errno; + if (signal_number == SIGWINCH) resized = 1; + else stopping = 1; + if (wake_write >= 0) { + const char byte = 1; + (void)write((int)wake_write, &byte, 1); + } + errno = saved_errno; +} + +static bool valid_key(const char *key) { + size_t length = strlen(key); + if (!length || length > 64) return false; + for (size_t i = 0; i < length; ++i) { + unsigned char c = (unsigned char)key[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-')) return false; + } + return true; +} + +static int compare_document_key(const void *left, const void *right) { + return strcmp(((const DocumentKey *)left)->name, ((const DocumentKey *)right)->name); +} + +static void close_list(Editor *editor) { + free(editor->documents); + editor->documents = NULL; + editor->document_count = editor->document_selected = editor->document_scroll = 0; + editor->prompt_size = 0; + editor->prompt[0] = 0; + editor->list_open = false; +} + +/* Names are already sorted; find the first key with this prefix in-place. */ +static bool find_list_prefix(Editor *editor) { + if (!editor->prompt_size) return true; + size_t first = 0, end = editor->document_count; + while (first < end) { + size_t middle = first + (end - first) / 2; + if (strcmp(editor->documents[middle].name, editor->prompt) < 0) first = middle + 1; + else end = middle; + } + if (first == editor->document_count || + strncmp(editor->documents[first].name, editor->prompt, editor->prompt_size)) return false; + editor->document_selected = first; + return true; +} + +/* List names from the selected byte store, not from the execution core. + * Opening a name still goes through the store's checked READ path. Bound the + * optional browser to 8,192 names (520 KiB of key storage). */ +static bool open_list(Editor *editor) { + int descriptor = open(editor->store, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + DIR *directory = descriptor >= 0 ? fdopendir(descriptor) : NULL; + if (!directory) { + if (descriptor >= 0) close(descriptor); + snprintf(editor->message, sizeof(editor->message), "Could not read document directory"); + return false; + } + DocumentKey *items = NULL; + size_t count = 0, capacity = 0; + bool complete = true; + for (;;) { + errno = 0; + struct dirent *entry = readdir(directory); + if (!entry) { if (errno) complete = false; break; } + if (strncmp(entry->d_name, "obj-", 4) || !valid_key(entry->d_name + 4)) continue; + if (count == 8192) { complete = false; break; } + if (count == capacity) { + size_t next = capacity ? capacity * 2 : 32; + DocumentKey *grown = realloc(items, next * sizeof(*grown)); + if (!grown) { complete = false; break; } + items = grown; + capacity = next; + } + memcpy(items[count++].name, entry->d_name + 4, strlen(entry->d_name + 4) + 1); + } + if (closedir(directory)) complete = false; + if (!complete) { + free(items); + snprintf(editor->message, sizeof(editor->message), "Document list exceeds 8,192 keys or cannot be read"); + return false; + } + if (count > 1) qsort(items, count, sizeof(*items), compare_document_key); + close_list(editor); + editor->documents = items; + editor->document_count = count; + for (size_t i = 0; i < count; ++i) + if (!strcmp(items[i].name, editor->key)) { editor->document_selected = i; break; } + editor->list_open = true; + return true; +} + +static const char *result_name(PureResult result) { + switch (result) { + case PURE_NOT_FOUND: return "not found"; + case PURE_CONFLICT: return "version conflict"; + case PURE_BUSY: return "busy"; + case PURE_BUDGET: return "request budget exceeded"; + case PURE_LIMIT: return "document limit or invalid range"; + case PURE_UNCERTAIN: return "durability unknown; inspect the stored revision before retrying"; + case PURE_DENIED: return "operation denied"; + case PURE_ALLOCATION: return "allocation failed"; + default: return "operation failed"; + } +} + +static bool edit_layout(const PureProgram *program, size_t *prefix) { + if (!program->splice || !program->write.to_end || program->write_step || + program->input_steps || !program->splice_offset.from_u32le || + !program->splice_remove.from_u32le || program->read.offset || + !program->read.to_end || program->read_required) return false; + size_t start = program->splice_offset.value, remove = program->splice_remove.value; + *prefix = program->write.offset; + if (*prefix > PURE_OBJECT_LIMIT || program->input_required > *prefix || + start > *prefix || remove > *prefix || *prefix - start < 4 || + *prefix - remove < 4) return false; + return start < remove ? remove - start >= 4 : start - remove >= 4; +} + +static void put32(unsigned char *to, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) to[i] = (unsigned char)(value >> (8 * i)); +} + +static PureOutcome finish(Editor *editor, PureTicket ticket) { + PureOutcome result; + while (!pure_runtime_result(ticket, &result)) + if (!pure_runtime_step(&editor->runtime)) return (PureOutcome){.status = PURE_INVALID}; + return result; +} + +static size_t length(const Editor *editor) { + return editor->capacity - (editor->gap_end - editor->gap_start); +} + +static unsigned char byte_at(const Editor *editor, size_t index) { + return editor->text[index < editor->gap_start ? index : + index + editor->gap_end - editor->gap_start]; +} + +/* Skip printable ASCII directly within one side of the gap. Its terminal + * width is always one cell; UTF-8, controls and tabs use the glyph path. */ +static size_t plain_run(const Editor *editor, size_t position, size_t maximum) { + size_t available = position < editor->gap_start ? editor->gap_start - position + : length(editor) - position; + if (available > maximum) available = maximum; + const unsigned char *bytes = editor->text + (position < editor->gap_start + ? position : position + editor->gap_end - editor->gap_start); + size_t count = 0; + while (count < available && bytes[count] >= 32 && bytes[count] < 127) ++count; + return count; +} + +static void advance_plain(size_t count, size_t columns, size_t *row, size_t *column) { + size_t cells = *column + count; + *row += cells / columns; + *column = cells % columns; +} + +static void changed_at(Editor *editor, size_t position) { + if (editor->anchor_valid && position < editor->anchor_position) + editor->anchor_valid = false; +} + +static void move_gap(Editor *editor, size_t position) { + if (position < editor->gap_start) { + size_t count = editor->gap_start - position; + memmove(editor->text + editor->gap_end - count, editor->text + position, count); + editor->gap_start -= count; + editor->gap_end -= count; + } else if (position > editor->gap_start) { + size_t count = position - editor->gap_start; + memmove(editor->text + editor->gap_start, editor->text + editor->gap_end, count); + editor->gap_start += count; + editor->gap_end += count; + } +} + +static bool reserve_gap(Editor *editor, size_t needed) { + if (editor->gap_end - editor->gap_start >= needed) return true; + size_t used = length(editor); + if (needed > PURE_OBJECT_LIMIT - used) return false; + size_t target = editor->capacity * 2; + if (target < used + needed + 256) target = used + needed + 256; + if (target > PURE_OBJECT_LIMIT + 256) target = PURE_OBJECT_LIMIT + 256; + unsigned char *fresh = malloc(target); + if (!fresh) return false; + size_t right = editor->capacity - editor->gap_end; + memcpy(fresh, editor->text, editor->gap_start); + memcpy(fresh + target - right, editor->text + editor->gap_end, right); + free(editor->text); + editor->text = fresh; + editor->gap_end = target - right; + editor->capacity = target; + return true; +} + +static bool insert_byte(Editor *editor, unsigned char byte) { + if (length(editor) >= PURE_OBJECT_LIMIT || !reserve_gap(editor, 1)) return false; + changed_at(editor, editor->gap_start); + editor->text[editor->gap_start++] = byte; + editor->dirty = true; + return true; +} + +static size_t previous_codepoint(const Editor *editor, size_t position) { + if (!position) return 0; + --position; + while (position && (byte_at(editor, position) & 0xc0) == 0x80) --position; + return position; +} + +static size_t next_codepoint(const Editor *editor, size_t position) { + size_t count = length(editor); + if (position == count) return count; + ++position; + while (position < count && (byte_at(editor, position) & 0xc0) == 0x80) ++position; + return position; +} + +static bool valid_text_span(PureSpan span) { + for (size_t position = 0; position < span.size;) { + mbstate_t state = {0}; + wchar_t character; + size_t used = mbrtowc(&character, (const char *)span.data + position, + span.size - position, &state); + if (used == (size_t)-1 || used == (size_t)-2 || !used || + (character < 32 && character != L'\n' && character != L'\t') || + character == 127 || + (character != L'\n' && character != L'\t' && wcwidth(character) < 0)) return false; + position += used; + } + return true; +} + +static bool read_document(Editor *editor, const char *key, bool require_existing) { + PureTicket ticket; + PureResult admitted = pure_runtime_accept(&editor->runtime, PURE_INPUT_READ, + key, 0, 0, &ticket); + if (admitted != PURE_OK) { + snprintf(editor->message, sizeof(editor->message), "Read rejected: %s", result_name(admitted)); + return false; + } + PureOutcome result = finish(editor, ticket); + if (require_existing && result.status == PURE_NOT_FOUND) { + snprintf(editor->message, sizeof(editor->message), "Document disappeared; refresh the list"); + pure_runtime_release(ticket); + return false; + } + if (result.status != PURE_OK && result.status != PURE_NOT_FOUND) { + snprintf(editor->message, sizeof(editor->message), "Read failed: %s", result_name(result.status)); + pure_runtime_release(ticket); + return false; + } + size_t size = result.status == PURE_OK ? result.body.size : 0; + if (size > PURE_OBJECT_LIMIT || (size && !result.body.data) || + !valid_text_span(result.body)) { + snprintf(editor->message, sizeof(editor->message), "Document is too large or is not plain UTF-8 text"); + pure_runtime_release(ticket); + return false; + } + size_t capacity = size + 256; + unsigned char *fresh = malloc(capacity); + if (!fresh) { + snprintf(editor->message, sizeof(editor->message), "Could not allocate document buffer"); + pure_runtime_release(ticket); + return false; + } + if (size) memcpy(fresh, result.body.data, size); + pure_runtime_release(ticket); + free(editor->text); + editor->text = fresh; + editor->capacity = capacity; + editor->gap_start = size; + editor->gap_end = capacity; + editor->scroll_row = 0; + editor->anchor_valid = false; + editor->revision = result.status == PURE_OK ? result.revision : 0; + editor->exists = result.status == PURE_OK; + editor->dirty = false; + editor->confirm_reload = false; + memcpy(editor->key, key, strlen(key) + 1); + snprintf(editor->message, sizeof(editor->message), "%s %zu bytes · revision %" PRId64, + editor->exists ? "Opened" : "New document", size, editor->revision); + return true; +} + +static void save_document(Editor *editor) { + if (!editor->dirty && editor->exists) { + snprintf(editor->message, sizeof(editor->message), "No changes to save"); + return; + } + PureTicket baseline; + PureResult admitted = pure_runtime_accept(&editor->runtime, PURE_INPUT_READ, + editor->key, 0, 0, &baseline); + if (admitted != PURE_OK) { + snprintf(editor->message, sizeof(editor->message), "Read rejected: %s", result_name(admitted)); + return; + } + PureOutcome old = finish(editor, baseline); + if (old.status != PURE_OK && old.status != PURE_NOT_FOUND) { + snprintf(editor->message, sizeof(editor->message), "Could not inspect stored version: %s", result_name(old.status)); + pure_runtime_release(baseline); + return; + } + int64_t stored_revision = old.status == PURE_OK ? old.revision : 0; + if (stored_revision != editor->revision) { + snprintf(editor->message, sizeof(editor->message), "Version conflict: stored %" PRId64 "; draft preserved", + stored_revision); + pure_runtime_release(baseline); + return; + } + size_t old_size = old.status == PURE_OK ? old.body.size : 0; + size_t draft_size = length(editor); + size_t prefix = 0; + while (prefix < old_size && prefix < draft_size && + old.body.data[prefix] == byte_at(editor, prefix)) ++prefix; + while (prefix && prefix < old_size && (old.body.data[prefix] & 0xc0) == 0x80) --prefix; + size_t suffix = 0; + while (suffix < old_size - prefix && suffix < draft_size - prefix && + old.body.data[old_size - suffix - 1] == byte_at(editor, draft_size - suffix - 1)) ++suffix; + while (suffix && (((old.body.data[old_size - suffix] & 0xc0) == 0x80) || + ((byte_at(editor, draft_size - suffix) & 0xc0) == 0x80))) --suffix; + size_t remove = old_size - prefix - suffix; + size_t insert = draft_size - prefix - suffix; + if (!remove && !insert && editor->exists) { + editor->dirty = false; + snprintf(editor->message, sizeof(editor->message), "No changes to save"); + pure_runtime_release(baseline); + return; + } + if (prefix > UINT32_MAX || remove > UINT32_MAX || insert > PURE_OBJECT_LIMIT - editor->prefix) { + snprintf(editor->message, sizeof(editor->message), "Edit exceeds selected program limit"); + pure_runtime_release(baseline); + return; + } + PureTicket write_ticket; + admitted = pure_runtime_accept(&editor->runtime, PURE_INPUT_WRITE, editor->key, + editor->revision, editor->prefix + insert, &write_ticket); + if (admitted != PURE_OK) { + snprintf(editor->message, sizeof(editor->message), "Write rejected: %s", result_name(admitted)); + pure_runtime_release(baseline); + return; + } + unsigned char *input = pure_runtime_ingress(write_ticket, NULL); + memset(input, 0, editor->prefix); + put32(input + editor->program->splice_offset.value, (uint32_t)prefix); + put32(input + editor->program->splice_remove.value, (uint32_t)remove); + for (size_t i = 0; i < insert; ++i) input[editor->prefix + i] = byte_at(editor, prefix + i); + pure_runtime_release(baseline); + if (!pure_runtime_ready(write_ticket)) { + pure_runtime_release(write_ticket); + snprintf(editor->message, sizeof(editor->message), "Could not publish edit"); + return; + } + PureOutcome result = finish(editor, write_ticket); + pure_runtime_release(write_ticket); + if (result.status != PURE_OK) { + snprintf(editor->message, sizeof(editor->message), "Save failed: %s; draft preserved", result_name(result.status)); + return; + } + editor->revision = result.revision; + editor->exists = true; + editor->dirty = false; + editor->confirm_quit = editor->confirm_reload = false; + snprintf(editor->message, sizeof(editor->message), "Saved %zu-byte edit at byte %zu · revision %" PRId64, + insert, prefix, result.revision); +} + +static size_t glyph(const Editor *editor, size_t position, wchar_t *character, + int *width, char bytes[4], bool *valid) { + unsigned char first = byte_at(editor, position); + if (first < 128) { + bytes[0] = (char)first; + *character = (wchar_t)first; + *width = 1; + if ((first < 32 && first != '\n' && first != '\t') || first == 127 || !first) { + *valid = false; + bytes[0] = '?'; + *character = L'?'; + } + return 1; + } + size_t available = length(editor) - position; + if (available > 4) available = 4; + for (size_t i = 0; i < available; ++i) bytes[i] = (char)byte_at(editor, position + i); + mbstate_t state = {0}; + size_t used = mbrtowc(character, bytes, available, &state); + if (used == (size_t)-1 || used == (size_t)-2 || !used || + (*character < 32 && *character != L'\n' && *character != L'\t') || + *character == 127) { + *valid = false; + *character = L'?'; + *width = 1; + bytes[0] = '?'; + return 1; + } + *width = wcwidth(*character); + if (*width < 0) { + *valid = false; + *character = L'?'; + *width = 1; + bytes[0] = '?'; + return 1; + } + return used; +} + +static bool text_valid(const Editor *editor) { + for (size_t position = 0; position < length(editor);) { + wchar_t character; + int width; + char bytes[4]; + bool valid = true; + size_t used = glyph(editor, position, &character, &width, bytes, &valid); + if (!valid) return false; + position += used; + } + return true; +} + +static void advance(wchar_t character, int width, size_t columns, + size_t *row, size_t *column) { + if (character == L'\n') { ++*row; *column = 0; return; } + if (character == L'\t') width = 4 - (int)(*column % 4); + if (*column && *column + (size_t)width > columns) { ++*row; *column = 0; } + *column += (size_t)width; + if (*column >= columns) { ++*row; *column = 0; } +} + +static void position_of(const Editor *editor, size_t target, size_t columns, + size_t *row, size_t *column) { + size_t position = 0; + *row = *column = 0; + if (editor->anchor_valid && editor->anchor_columns == columns && + target >= editor->anchor_position) { + position = editor->anchor_position; + *row = editor->anchor_row; + *column = editor->anchor_column; + } + for (; position < target;) { + size_t run = plain_run(editor, position, target - position); + if (run) { + advance_plain(run, columns, row, column); + position += run; + continue; + } + wchar_t character; + int width; + char bytes[4]; + bool valid = true; + size_t used = glyph(editor, position, &character, &width, bytes, &valid); + advance(character, width, columns, row, column); + position += used; + } +} + +static size_t closest_position(const Editor *editor, size_t target_row, + size_t target_column, size_t columns) { + size_t row = 0, column = 0, position = 0, best = 0; + bool found = false; + if (editor->anchor_valid && editor->anchor_columns == columns && + target_row > editor->anchor_row) { + row = editor->anchor_row; + column = editor->anchor_column; + position = editor->anchor_position; + } + for (; position < length(editor);) { + if (row == target_row && column <= target_column) { best = position; found = true; } + if (row > target_row || (row == target_row && column > target_column)) + return found ? best : position; + wchar_t character; + int width; + char bytes[4]; + bool valid = true; + size_t used = glyph(editor, position, &character, &width, bytes, &valid); + advance(character, width, columns, &row, &column); + position += used; + if (row == target_row && column <= target_column) { best = position; found = true; } + } + return found ? best : length(editor); +} + +static void dimensions(size_t *rows, size_t *columns) { + struct winsize window = {0}; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &window) || !window.ws_row || !window.ws_col) { + *rows = 24; *columns = 80; + } else { *rows = window.ws_row; *columns = window.ws_col; } + if (*rows < 4) *rows = 4; + if (*columns < 8) *columns = 8; +} + +static void draw_list(Editor *editor, size_t rows, size_t columns) { + size_t visible = rows - 3; + bool matching = editor->document_count && (!editor->prompt_size || + !strncmp(editor->documents[editor->document_selected].name, + editor->prompt, editor->prompt_size)); + if (editor->document_selected < editor->document_scroll) + editor->document_scroll = editor->document_selected; + if (editor->document_selected >= editor->document_scroll + visible) + editor->document_scroll = editor->document_selected - visible + 1; + fputs("\033[?25l\033[H\033[2J", stdout); + char header[80]; + snprintf(header, sizeof(header), " Pure | documents | %zu keys", editor->document_count); + printf("%.*s", (int)columns, header); + for (size_t row = 0; row < visible && editor->document_scroll + row < editor->document_count; ++row) { + size_t index = editor->document_scroll + row; + printf("\033[%zu;1H%c %.*s", row + 2, + matching && index == editor->document_selected ? '>' : ' ', + (int)(columns - 2), editor->documents[index].name); + } + printf("\033[%zu;1H%.*s", rows - 1, (int)columns, + " Enter open Type to find Backspace clear Esc back"); + char footer[230]; + if (editor->prompt_size) + snprintf(footer, sizeof(footer), " Find: %.64s_ %.140s", editor->prompt, + matching ? editor->message : "No matching key"); + else if (editor->document_count) + snprintf(footer, sizeof(footer), " %zu/%zu %s", + editor->document_selected + 1, editor->document_count, editor->message); + else snprintf(footer, sizeof(footer), " No stored documents; Ctrl+O creates a new key"); + printf("\033[%zu;1H%.*s", rows, (int)columns, footer); + fflush(stdout); +} + +static void draw(Editor *editor) { + size_t rows, columns; + dimensions(&rows, &columns); + if (editor->list_open) { draw_list(editor, rows, columns); return; } + if (editor->anchor_columns != columns) editor->anchor_valid = false; + size_t content_rows = rows - 3; + size_t cursor_row, cursor_column; + position_of(editor, editor->gap_start, columns, &cursor_row, &cursor_column); + if (cursor_row < editor->scroll_row) editor->scroll_row = cursor_row; + if (cursor_row >= editor->scroll_row + content_rows) + editor->scroll_row = cursor_row - content_rows + 1; + fputs("\033[?25l\033[H\033[2J", stdout); + char header[160]; + snprintf(header, sizeof(header), " Pure | %s | revision %" PRId64 "%s | %zu bytes", + editor->key, editor->revision, editor->dirty ? " *" : "", length(editor)); + printf("%.*s", (int)columns, header); + size_t row = 0, column = 0, position = 0, drawn_row = SIZE_MAX; + size_t anchor_row = editor->scroll_row > content_rows + ? editor->scroll_row - content_rows : 0; + bool keep_anchor = editor->anchor_valid && editor->anchor_row <= editor->scroll_row && + editor->scroll_row - editor->anchor_row <= 2 * content_rows; + if (editor->anchor_valid && editor->anchor_row <= editor->scroll_row) { + row = editor->anchor_row; + column = editor->anchor_column; + position = editor->anchor_position; + } + if (!keep_anchor) editor->anchor_valid = false; + for (; position < length(editor);) { + if (!editor->anchor_valid && row >= anchor_row) { + editor->anchor_position = position; + editor->anchor_row = row; + editor->anchor_column = column; + editor->anchor_columns = columns; + editor->anchor_valid = true; + } + if (row >= editor->scroll_row + content_rows) break; + if (row < editor->scroll_row) { + size_t remaining = length(editor) - position; + size_t rows_to_view = editor->scroll_row - row; + size_t cells_to_view = rows_to_view > (remaining + column) / columns + ? remaining : rows_to_view * columns - column; + size_t run = plain_run(editor, position, cells_to_view); + if (run) { + advance_plain(run, columns, &row, &column); + position += run; + continue; + } + } + wchar_t character; + int width; + char bytes[4]; + bool valid = true; + size_t used = glyph(editor, position, &character, &width, bytes, &valid); + size_t next_row = row, next_column = column; + size_t visible_width = character == L'\t' ? 4 - column % 4 : (size_t)width; + if (character != L'\n' && column && column + visible_width > columns) { + ++row; + column = 0; + } + if (row >= editor->scroll_row && row < editor->scroll_row + content_rows && + character != L'\n') { + if (drawn_row != row) { + printf("\033[%zu;1H", row - editor->scroll_row + 2); + drawn_row = row; + } + if (character == L'\t') { + int spaces = 4 - (int)(column % 4); + while (spaces--) putchar(' '); + } else fwrite(bytes, 1, used, stdout); + } + row = next_row; + column = next_column; + advance(character, width, columns, &row, &column); + position += used; + } + printf("\033[%zu;1H%.*s", rows - 1, (int)columns, + " Ctrl+S save Ctrl+O open Ctrl+R reload Ctrl+Q quit arrows move"); + char footer[230]; + if (editor->prompt_open) + snprintf(footer, sizeof(footer), " Open key: %s_ (Enter to open, Esc to cancel)", editor->prompt); + else snprintf(footer, sizeof(footer), " %s", editor->message); + printf("\033[%zu;1H%.*s", rows, (int)columns, footer); + if (editor->prompt_open) { + size_t prompt_column = editor->prompt_size + strlen(" Open key: ") + 1; + if (prompt_column > columns) prompt_column = columns; + printf("\033[%zu;%zuH", rows, prompt_column); + } else printf("\033[%zu;%zuH", cursor_row - editor->scroll_row + 2, + cursor_column + 1); + fputs("\033[?25h", stdout); + fflush(stdout); +} + +static int read_byte(int wake_read, int timeout_ms) { + struct pollfd fds[2] = {{.fd = STDIN_FILENO, .events = POLLIN}, + {.fd = wake_read, .events = POLLIN}}; + int ready = poll(fds, 2, timeout_ms); + if (ready < 0) return errno == EINTR ? -2 : -1; + if (!ready) return -3; + if (fds[1].revents) { + char buffer[32]; + while (read(wake_read, buffer, sizeof(buffer)) > 0) { } + return -2; + } + unsigned char byte; + return read(STDIN_FILENO, &byte, 1) == 1 ? byte : -1; +} + +static void move_vertical(Editor *editor, int delta) { + size_t rows, columns, row, column; + dimensions(&rows, &columns); + (void)rows; + position_of(editor, editor->gap_start, columns, &row, &column); + if (delta < 0 && !row) return; + move_gap(editor, closest_position(editor, delta < 0 ? row - 1 : row + 1, + column, columns)); +} + +static void handle_escape(Editor *editor, int wake_read) { + int a = read_byte(wake_read, 30); + if (a != '[') { + if (editor->list_open) close_list(editor); + editor->prompt_open = false; + return; + } + int b = read_byte(wake_read, 30); + if (editor->list_open) { + if (b == 'A' || b == 'B' || b == 'H' || b == 'F') { + editor->prompt_size = 0; + editor->prompt[0] = 0; + } + if (b == 'A' && editor->document_selected) --editor->document_selected; + else if (b == 'B' && editor->document_selected + 1 < editor->document_count) + ++editor->document_selected; + else if (b == 'H') editor->document_selected = 0; + else if (b == 'F' && editor->document_count) + editor->document_selected = editor->document_count - 1; + return; + } + if (b == 'A') move_vertical(editor, -1); + else if (b == 'B') move_vertical(editor, 1); + else if (b == 'C') move_gap(editor, next_codepoint(editor, editor->gap_start)); + else if (b == 'D') move_gap(editor, previous_codepoint(editor, editor->gap_start)); + else if (b == 'H') { + size_t position = editor->gap_start; + while (position && byte_at(editor, position - 1) != '\n') --position; + move_gap(editor, position); + } else if (b == 'F') { + size_t position = editor->gap_start; + while (position < length(editor) && byte_at(editor, position) != '\n') ++position; + move_gap(editor, position); + } else if (b == '3' && read_byte(wake_read, 30) == '~') { + size_t next = next_codepoint(editor, editor->gap_start); + if (next > editor->gap_start) { + changed_at(editor, editor->gap_start); + editor->gap_end += next - editor->gap_start; + editor->dirty = true; + } + } +} + +static void handle_key(Editor *editor, int key, int wake_read) { + if (key < 0) return; + if (editor->prompt_open) { + if (key == 27) { + if (read_byte(wake_read, 30) == '[') { + int next = read_byte(wake_read, 30); + if (next >= '0' && next <= '9') (void)read_byte(wake_read, 30); + } else editor->prompt_open = false; + return; + } + if (key == 127 || key == 8) { + if (editor->prompt_size) editor->prompt[--editor->prompt_size] = 0; + return; + } + if (key == 13 || key == 10) { + if (valid_key(editor->prompt)) (void)read_document(editor, editor->prompt, false); + else snprintf(editor->message, sizeof(editor->message), "Key must be 1–64 ASCII letters, digits, _ or -"); + editor->prompt_open = false; + return; + } + if (editor->prompt_size < 64 && + ((key >= 'a' && key <= 'z') || (key >= 'A' && key <= 'Z') || + (key >= '0' && key <= '9') || key == '_' || key == '-')) { + editor->prompt[editor->prompt_size++] = (char)key; + editor->prompt[editor->prompt_size] = 0; + } + return; + } + if (editor->list_open) { + if (key == 27) { handle_escape(editor, wake_read); return; } + if ((key == 127 || key == 8) && editor->prompt_size) { + editor->prompt[--editor->prompt_size] = 0; + (void)find_list_prefix(editor); + return; + } + if (editor->prompt_size < 64 && + ((key >= 'a' && key <= 'z') || (key >= 'A' && key <= 'Z') || + (key >= '0' && key <= '9') || key == '_' || key == '-')) { + editor->prompt[editor->prompt_size++] = (char)key; + editor->prompt[editor->prompt_size] = 0; + (void)find_list_prefix(editor); + return; + } + if ((key == 13 || key == 10) && editor->document_count && + (!editor->prompt_size || !strncmp(editor->documents[editor->document_selected].name, + editor->prompt, editor->prompt_size))) { + if (read_document(editor, editor->documents[editor->document_selected].name, true)) + close_list(editor); + return; + } + if (key == 12) { (void)open_list(editor); return; } + if (key == 15) close_list(editor); + else if (key != 17 && key != 3) return; + } + if (key == 17 || key == 3) { + if (editor->dirty && !editor->confirm_quit) { + editor->confirm_quit = true; + snprintf(editor->message, sizeof(editor->message), "Unsaved draft; press Ctrl+Q again to discard and quit"); + } else stopping = 1; + return; + } + if (key != 18) editor->confirm_reload = false; + if (key != 17) editor->confirm_quit = false; + if (key == 19) { if (text_valid(editor)) save_document(editor); + else snprintf(editor->message, sizeof(editor->message), "Document contains invalid UTF-8 or control bytes"); + return; } + if (key == 18) { + if (editor->dirty && !editor->confirm_reload) { + editor->confirm_reload = true; + snprintf(editor->message, sizeof(editor->message), "Unsaved draft; press Ctrl+R again to discard and reload"); + } else (void)read_document(editor, editor->key, false); + return; + } + if (key == 15) { + if (editor->dirty) snprintf(editor->message, sizeof(editor->message), "Save or reload the draft before opening another key"); + else { editor->prompt_open = true; editor->prompt_size = 0; editor->prompt[0] = 0; } + return; + } + if (key == 12) { + if (editor->dirty) + snprintf(editor->message, sizeof(editor->message), "Save or reload the draft before browsing documents"); + else (void)open_list(editor); + return; + } + if (key == 27) { handle_escape(editor, wake_read); return; } + if (key == 127 || key == 8) { + size_t before = previous_codepoint(editor, editor->gap_start); + if (before < editor->gap_start) { + changed_at(editor, before); + editor->gap_start = before; + editor->dirty = true; + } + return; + } + if (key == 13) key = '\n'; + if (key == '\n' || key == '\t' || key >= 32) { + if (!insert_byte(editor, (unsigned char)key)) + snprintf(editor->message, sizeof(editor->message), "Document reached the byte limit"); + } +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +static bool raw_terminal(const struct termios *original) { + struct termios raw = *original; + raw.c_iflag &= (tcflag_t)~(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + raw.c_oflag &= (tcflag_t)~OPOST; + raw.c_cflag |= CS8; + raw.c_lflag &= (tcflag_t)~(ECHO | ICANON | IEXTEN | ISIG); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + return tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == 0; +} + +int main(int argc, char **argv) { +#ifdef PURE_EMBEDDED_PROGRAM + if (argc != 4 || strcmp(argv[1], "--store") || !valid_key(argv[3])) { + fputs("usage: pure-edit-tui-fixed --store DIRECTORY KEY\n", stderr); + return 2; + } + const char *store_path = argv[2], *key = argv[3]; + const PureProgram *program = &pure_embedded_program; +#else + if (argc != 6 || strcmp(argv[1], "--program") || strcmp(argv[3], "--store") || + !valid_key(argv[5])) { + fputs("usage: pure-edit-tui --program FILE --store DIRECTORY KEY\n", stderr); + return 2; + } + const char *store_path = argv[4], *key = argv[5]; + PureProgram loaded; + char error[160]; + if (!pure_program_load(argv[2], &loaded, error, sizeof(error))) { + fprintf(stderr, "Program: %s\n", error); + return 2; + } + const PureProgram *program = &loaded; +#endif + size_t prefix; + if (!edit_layout(program, &prefix)) { + fputs("Program needs a complete-document read and two u32le splice fields with trailing insertion bytes\n", stderr); + return 2; + } + if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO)) { + fputs("terminal editor requires a TTY\n", stderr); + return 2; + } + setlocale(LC_CTYPE, ""); + Editor editor = {.store = store_path, .program = program, .prefix = prefix}; + if (pure_store_init(editor.store) != PURE_OK || + !pure_runtime_init(&editor.runtime, editor.work, 2, program, + pure_store_host(editor.store), 4u * 1024u * 1024u) || + !read_document(&editor, key, false)) { + fprintf(stderr, "Could not open document: %s\n", editor.message); + free(editor.text); + return 2; + } + int wake[2]; + if (pipe(wake) || !nonblocking(wake[0]) || !nonblocking(wake[1]) || + tcgetattr(STDIN_FILENO, &editor.original_term)) { + perror("terminal setup"); + free(editor.text); + return 2; + } + wake_write = wake[1]; + struct sigaction action = {.sa_handler = wake_signal}; + sigemptyset(&action.sa_mask); + if (sigaction(SIGINT, &action, NULL) || sigaction(SIGTERM, &action, NULL) || + sigaction(SIGWINCH, &action, NULL)) { + perror("sigaction"); + wake_write = -1; + close(wake[0]); + close(wake[1]); + free(editor.text); + return 2; + } + if (!raw_terminal(&editor.original_term)) { + perror("raw terminal"); + tcsetattr(STDIN_FILENO, TCSAFLUSH, &editor.original_term); + wake_write = -1; + close(wake[0]); + close(wake[1]); + free(editor.text); + return 2; + } + fputs("\033[?1049h", stdout); + while (!stopping) { + draw(&editor); + int key = read_byte(wake[0], -1); + if (resized) resized = 0; + if (key == -1) { stopping = 1; break; } + handle_key(&editor, key, wake[0]); + } + fputs("\033[?25h\033[?1049l", stdout); + fflush(stdout); + wake_write = -1; + close(wake[0]); + close(wake[1]); + tcsetattr(STDIN_FILENO, TCSAFLUSH, &editor.original_term); + pure_runtime_stop(&editor.runtime); + close_list(&editor); + free(editor.text); + return 0; +} diff --git a/prototypes/connections/edit-window.c b/prototypes/connections/edit-window.c new file mode 100644 index 0000000..303b2bf --- /dev/null +++ b/prototypes/connections/edit-window.c @@ -0,0 +1,638 @@ +#define _POSIX_C_SOURCE 200809L +/* Optional GTK document adapter for the same prepared edit program used by + * pure-edit and pure-connections. GTK text lives at the application boundary; + * Pure's execution core remains independent of the UI and document encoding. */ +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include + +typedef struct { + PureProgram program; + PureRuntime runtime; + PureWork work[1]; + const char *directory, *program_path; + char key[65]; + size_t prefix, baseline_size; + char *baseline; + char *definition_baseline; + size_t definition_baseline_size; + uint64_t definition_generation; + size_t last_output_bytes; + bool has_edit_output; + bool definition_needs_apply; + int64_t revision; + bool exists; + bool closing, asking_to_close; + GtkWidget *window, *text, *status, *version, *heading, *key_entry; + GtkWidget *source, *source_revealer, *source_status; +} Editor; + +static const char *result_name(PureResult result) { + switch (result) { + case PURE_OK: return "ok"; + case PURE_NOT_FOUND: return "not found"; + case PURE_CONFLICT: return "version conflict"; + case PURE_BUSY: return "busy"; + case PURE_BUDGET: return "request budget exceeded"; + case PURE_LIMIT: return "document limit or invalid range"; + case PURE_UNCERTAIN: return "durability unknown; inspect the stored revision before retrying"; + case PURE_DENIED: return "operation denied"; + case PURE_ALLOCATION: return "allocation failed"; + default: return "operation failed"; + } +} + +static bool valid_key(const char *key) { + size_t length = strlen(key); + if (!length || length > 64) return false; + for (size_t i = 0; i < length; ++i) { + char c = key[i]; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-')) return false; + } + return true; +} + +/* The window supplies the convenience layout accepted by pure-edit. The + * prepared program, not the window, determines the splice field locations. */ +static bool edit_layout(const PureProgram *p, size_t *prefix) { + if (!p->splice || !p->write.to_end || p->write_step || p->input_steps || + !p->splice_offset.from_u32le || !p->splice_remove.from_u32le || + p->read.offset || !p->read.to_end || p->read_required) return false; + size_t start = p->splice_offset.value, remove = p->splice_remove.value; + *prefix = p->write.offset; + if (*prefix > PURE_OBJECT_LIMIT || p->input_required > *prefix || start > *prefix || + remove > *prefix || *prefix - start < 4 || *prefix - remove < 4) return false; + return start < remove ? remove - start >= 4 : start - remove >= 4; +} + +static void put32(unsigned char *to, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) to[i] = (unsigned char)(value >> (8 * i)); +} + +static void status(Editor *e, const char *text) { + gtk_label_set_text(GTK_LABEL(e->status), text); + char revision[64]; + snprintf(revision, sizeof(revision), "%s · revision %" PRId64, + e->exists ? "Stored" : "New", e->revision); + gtk_label_set_text(GTK_LABEL(e->version), revision); +} + +static void definition_status(Editor *e, const char *text) { + gtk_label_set_text(GTK_LABEL(e->source_status), text); +} + +static void definition_title(Editor *e) { + char title[128]; + if (e->has_edit_output) + snprintf(title, sizeof(title), "%s · Pure document · definition %" PRIu64 + " · output %zu B", e->key, e->definition_generation, e->last_output_bytes); + else snprintf(title, sizeof(title), "%s · Pure document · definition %" PRIu64, + e->key, e->definition_generation); + gtk_window_set_title(GTK_WINDOW(e->window), title); +} + +static void apply_definition(Editor *e) { + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->source)); + GtkTextIter first, last; + gtk_text_buffer_get_bounds(buffer, &first, &last); + char *draft = gtk_text_buffer_get_text(buffer, &first, &last, FALSE); + size_t length = strlen(draft); + bool changed = length != e->definition_baseline_size || + memcmp(draft, e->definition_baseline, length) != 0; + if (!changed && !e->definition_needs_apply) { + gtk_text_buffer_set_modified(buffer, FALSE); + definition_status(e, "No definition changes to apply"); + g_free(draft); + return; + } + PureProgram candidate; + size_t prefix = 0; + char error[160] = {0}; + if (length > PURE_PROGRAM_LIMIT || + !pure_program_parse(draft, length, &candidate, error, sizeof(error)) || + !edit_layout(&candidate, &prefix)) { + char message[240]; + snprintf(message, sizeof(message), "Definition rejected: %s; draft preserved", + error[0] ? error : "document edit/read contract required"); + definition_status(e, message); + g_free(draft); + return; + } + char *on_disk = NULL; + gsize on_disk_size = 0; + GError *file_error = NULL; + bool read = g_file_get_contents(e->program_path, &on_disk, &on_disk_size, &file_error); + bool same = read && on_disk_size == e->definition_baseline_size && + !memcmp(on_disk, e->definition_baseline, on_disk_size); + g_free(on_disk); + if (file_error) g_error_free(file_error); + if (!same) { + definition_status(e, "Definition changed on disk; draft preserved. Reload or resolve it first"); + g_free(draft); + return; + } + if (e->runtime.active || e->runtime.running) { + definition_status(e, "A document operation is active; definition draft preserved"); + g_free(draft); + return; + } + if (changed && !g_file_set_contents_full(e->program_path, draft, (gssize)length, + G_FILE_SET_CONTENTS_CONSISTENT | G_FILE_SET_CONTENTS_DURABLE, 0600, &file_error)) { + char message[240]; + snprintf(message, sizeof(message), "Could not save definition: %s; draft preserved", + file_error ? file_error->message : "unknown error"); + definition_status(e, message); + if (file_error) g_error_free(file_error); + g_free(draft); + return; + } + PureProgram previous = e->program; + size_t previous_prefix = e->prefix; + e->program = candidate; + e->prefix = prefix; + if (!pure_runtime_restart(&e->runtime, &e->program, + pure_store_host(e->directory), 4u * 1024u * 1024u)) { + e->program = previous; + e->prefix = previous_prefix; + bool restored = !changed || g_file_set_contents_full(e->program_path, e->definition_baseline, + (gssize)e->definition_baseline_size, + G_FILE_SET_CONTENTS_CONSISTENT | G_FILE_SET_CONTENTS_DURABLE, 0600, NULL); + definition_status(e, restored ? "Could not install definition; previous source restored" : + "Definition installation failed; inspect source file before retrying"); + g_free(draft); + return; + } + g_free(e->definition_baseline); + e->definition_baseline = draft; + e->definition_baseline_size = length; + gtk_text_buffer_set_modified(buffer, FALSE); + e->definition_needs_apply = false; + ++e->definition_generation; + e->has_edit_output = false; + definition_title(e); + definition_status(e, "Applied locally & saved; other hosts load it after restart"); +} + +static void apply_definition_clicked(GtkButton *button, gpointer data) { + (void)button; + apply_definition(data); +} + +static void reload_definition(Editor *e) { + char *source = NULL; + gsize length = 0; + GError *error = NULL; + if (!g_file_get_contents(e->program_path, &source, &length, &error) || + length > PURE_PROGRAM_LIMIT || memchr(source, 0, length) || + !g_utf8_validate(source, (gssize)length, NULL)) { + definition_status(e, "Could not load a UTF-8 definition within 16 KiB; draft preserved"); + if (error) g_error_free(error); + g_free(source); + return; + } + g_free(e->definition_baseline); + e->definition_baseline = source; + e->definition_baseline_size = length; + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->source)); + gtk_text_buffer_set_text(buffer, source, (int)length); + gtk_text_buffer_set_modified(buffer, FALSE); + e->definition_needs_apply = true; + definition_status(e, "Definition reloaded from disk; Apply to run it"); +} + +static void reload_definition_clicked(GtkButton *button, gpointer data) { + (void)button; + Editor *e = data; + if (gtk_text_buffer_get_modified(gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->source)))) { + definition_status(e, "Definition draft has changes; use Discard & reload"); + return; + } + reload_definition(e); +} + +static void discard_definition_clicked(GtkButton *button, gpointer data) { + (void)button; + reload_definition(data); +} + +static void toggle_definition(Editor *e) { + bool shown = gtk_revealer_get_reveal_child(GTK_REVEALER(e->source_revealer)); + gtk_revealer_set_reveal_child(GTK_REVEALER(e->source_revealer), !shown); + gtk_widget_grab_focus(shown ? e->text : e->source); +} + +static void toggle_definition_clicked(GtkButton *button, gpointer data) { + (void)button; + toggle_definition(data); +} + +static PureOutcome finish(Editor *e, PureTicket ticket) { + PureOutcome result; + while (!pure_runtime_result(ticket, &result)) + if (!pure_runtime_step(&e->runtime)) return (PureOutcome){.status = PURE_INVALID}; + return result; +} + +static bool open_document_key(Editor *e, const char *key) { + PureTicket ticket; + PureResult accepted = pure_runtime_accept(&e->runtime, PURE_INPUT_READ, + key, 0, 0, &ticket); + if (accepted != PURE_OK) { status(e, result_name(accepted)); return false; } + PureOutcome result = finish(e, ticket); + char *copy = NULL; + if (result.status == PURE_OK) { + if (result.body.size > G_MAXINT || + (result.body.size && (!result.body.data || + memchr(result.body.data, 0, result.body.size) || + !g_utf8_validate((const char *)result.body.data, result.body.size, NULL)))) { + pure_runtime_release(ticket); + status(e, "Stored bytes are not plain UTF-8 text; use pure-edit for binary data"); + return false; + } + copy = g_malloc(result.body.size + 1); + if (result.body.size) memcpy(copy, result.body.data, result.body.size); + copy[result.body.size] = 0; + } + pure_runtime_release(ticket); + if (result.status != PURE_OK && result.status != PURE_NOT_FOUND) { + status(e, result_name(result.status)); + return false; + } + if (strcmp(key, e->key) != 0) memcpy(e->key, key, strlen(key) + 1); + g_free(e->baseline); + e->baseline = copy ? copy : g_strdup(""); + e->baseline_size = result.status == PURE_OK ? result.body.size : 0; + e->revision = result.status == PURE_OK ? result.revision : 0; + e->exists = result.status == PURE_OK; + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->text)); + gtk_text_buffer_set_text(buffer, e->baseline, (int)e->baseline_size); + gtk_text_buffer_set_modified(buffer, FALSE); + e->has_edit_output = false; + gtk_label_set_text(GTK_LABEL(e->heading), e->key); + definition_title(e); + char message[128]; + snprintf(message, sizeof(message), "%s · %zu bytes", e->exists ? "Loaded" : "New document", + e->baseline_size); + status(e, message); + return true; +} + +static void open_document(Editor *e) { + (void)open_document_key(e, e->key); +} + +static void open_key(Editor *e) { + GtkTextBuffer *document = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->text)); + GtkTextBuffer *definition = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->source)); + if (gtk_text_buffer_get_modified(document) || gtk_text_buffer_get_modified(definition)) { + status(e, "Save or discard the document and definition drafts before opening another key"); + return; + } + const char *key = gtk_editable_get_text(GTK_EDITABLE(e->key_entry)); + if (!valid_key(key)) { status(e, "Key must be 1–64 ASCII letters, digits, _ or -"); return; } + if (open_document_key(e, key)) gtk_widget_grab_focus(e->text); +} + +static void open_key_clicked(GtkButton *button, gpointer data) { + (void)button; + open_key(data); +} + +static void open_key_activated(GtkEntry *entry, gpointer data) { + (void)entry; + open_key(data); +} + +static void reload_clicked(GtkButton *button, gpointer data) { + (void)button; + Editor *e = data; + if (gtk_text_buffer_get_modified(gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->text)))) { + status(e, "Draft has changes; use Discard & reload to replace it"); + return; + } + open_document(e); +} + +static void discard_clicked(GtkButton *button, gpointer data) { + (void)button; + open_document(data); +} + +static bool continuation(unsigned char c) { return (c & 0xc0u) == 0x80u; } + +/* Find the smallest changed byte interval with valid UTF-8 boundaries. The + * storage effect still sees bytes; this only keeps the UI's edit interval + * meaningful when two code points happen to share leading/trailing bytes. */ +static void change_span(const char *old, size_t old_size, const char *draft, size_t draft_size, + size_t *start, size_t *remove, size_t *insert) { + size_t prefix = 0; + while (prefix < old_size && prefix < draft_size && old[prefix] == draft[prefix]) ++prefix; + while (prefix && prefix < old_size && continuation((unsigned char)old[prefix])) --prefix; + size_t suffix = 0; + while (suffix < old_size - prefix && suffix < draft_size - prefix && + old[old_size - suffix - 1] == draft[draft_size - suffix - 1]) ++suffix; + while (suffix && (continuation((unsigned char)old[old_size - suffix]) || + continuation((unsigned char)draft[draft_size - suffix]))) --suffix; + *start = prefix; + *remove = old_size - prefix - suffix; + *insert = draft_size - prefix - suffix; +} + +static void save_document(Editor *e) { + GtkTextBuffer *buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->text)); + GtkTextIter first, last; + gtk_text_buffer_get_bounds(buffer, &first, &last); + char *draft = gtk_text_buffer_get_text(buffer, &first, &last, FALSE); + size_t draft_size = strlen(draft); + size_t offset, remove, insert; + change_span(e->baseline, e->baseline_size, draft, draft_size, &offset, &remove, &insert); + if (e->exists && !remove && !insert) { + gtk_text_buffer_set_modified(buffer, FALSE); + status(e, "No changes to save"); + g_free(draft); + return; + } + if (draft_size > PURE_OBJECT_LIMIT || offset > UINT32_MAX || remove > UINT32_MAX || + insert > PURE_OBJECT_LIMIT - e->prefix) { + status(e, "Document or edit exceeds the selected byte limit"); + g_free(draft); + return; + } + PureTicket ticket; + PureResult accepted = pure_runtime_accept(&e->runtime, PURE_INPUT_WRITE, e->key, + e->revision, e->prefix + insert, &ticket); + if (accepted != PURE_OK) { + status(e, result_name(accepted)); + g_free(draft); + return; + } + unsigned char *input = pure_runtime_ingress(ticket, NULL); + memset(input, 0, e->prefix); + put32(input + e->program.splice_offset.value, (uint32_t)offset); + put32(input + e->program.splice_remove.value, (uint32_t)remove); + if (insert) memcpy(input + e->prefix, draft + offset, insert); + if (!pure_runtime_ready(ticket)) { + pure_runtime_release(ticket); + status(e, "Could not publish edit"); + g_free(draft); + return; + } + PureOutcome result = finish(e, ticket); + size_t reply_size = result.body.size; + pure_runtime_release(ticket); + if (result.status != PURE_OK) { + char message[160]; + if (result.status == PURE_CONFLICT) + snprintf(message, sizeof(message), "Version conflict: stored revision %" PRId64 + "; draft preserved", result.revision); + else snprintf(message, sizeof(message), "%s; draft preserved", result_name(result.status)); + status(e, message); + g_free(draft); + return; + } + g_free(e->baseline); + e->baseline = draft; + e->baseline_size = draft_size; + e->revision = result.revision; + e->exists = true; + e->last_output_bytes = reply_size; + e->has_edit_output = true; + definition_title(e); + gtk_text_buffer_set_modified(buffer, FALSE); + char message[128]; + snprintf(message, sizeof(message), "Saved %zu-byte edit at byte %zu · output %zu bytes", + insert, offset, reply_size); + status(e, message); +} + +static void save_clicked(GtkButton *button, gpointer data) { + (void)button; + save_document(data); +} + +static gboolean key_pressed(GtkEventControllerKey *controller, guint keyval, guint keycode, + GdkModifierType modifiers, gpointer data) { + (void)controller; (void)keycode; + Editor *e = data; + if ((modifiers & GDK_CONTROL_MASK) && (modifiers & GDK_SHIFT_MASK) && + (keyval == GDK_KEY_d || keyval == GDK_KEY_D)) { + toggle_definition(e); + return TRUE; + } + if ((modifiers & GDK_CONTROL_MASK) && + (keyval == GDK_KEY_o || keyval == GDK_KEY_O)) { + gtk_widget_grab_focus(e->key_entry); + return TRUE; + } + if ((modifiers & GDK_CONTROL_MASK) && + (keyval == GDK_KEY_s || keyval == GDK_KEY_S)) { + if ((modifiers & GDK_SHIFT_MASK) || + gtk_window_get_focus(GTK_WINDOW(e->window)) == e->source) + apply_definition(e); + else save_document(e); + return TRUE; + } + if ((modifiers & GDK_CONTROL_MASK) && + (keyval == GDK_KEY_w || keyval == GDK_KEY_W)) { + gtk_window_close(GTK_WINDOW(e->window)); + return TRUE; + } + return FALSE; +} + +static void close_decision(GObject *source, GAsyncResult *response, gpointer data) { + Editor *e = data; + GError *error = NULL; + int selected = gtk_alert_dialog_choose_finish(GTK_ALERT_DIALOG(source), response, &error); + if (error) g_error_free(error); + e->asking_to_close = false; + if (selected == 1) { + e->closing = true; + gtk_window_destroy(GTK_WINDOW(e->window)); + } +} + +static gboolean close_requested(GtkWindow *window, gpointer data) { + Editor *e = data; + bool document_changed = gtk_text_buffer_get_modified(gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->text))); + bool definition_changed = gtk_text_buffer_get_modified(gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->source))); + if (e->closing || (!document_changed && !definition_changed)) + return FALSE; + if (e->asking_to_close) return TRUE; + e->asking_to_close = true; + GtkAlertDialog *dialog = gtk_alert_dialog_new("Discard unsaved document or definition changes?"); + gtk_alert_dialog_set_detail(dialog, "The current draft has not been saved or applied."); + const char *buttons[] = {"Keep editing", "Discard changes", NULL}; + gtk_alert_dialog_set_buttons(dialog, buttons); + gtk_alert_dialog_set_cancel_button(dialog, 0); + gtk_alert_dialog_set_default_button(dialog, 0); + gtk_alert_dialog_choose(dialog, window, NULL, close_decision, e); + g_object_unref(dialog); + return TRUE; +} + +static void activate(GtkApplication *application, gpointer data) { + Editor *e = data; + if (e->window) { gtk_window_present(GTK_WINDOW(e->window)); return; } + e->window = gtk_application_window_new(application); + g_signal_connect(e->window, "close-request", G_CALLBACK(close_requested), e); + gtk_window_set_default_size(GTK_WINDOW(e->window), 960, 680); + definition_title(e); + GtkWidget *column = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_window_set_child(GTK_WINDOW(e->window), column); + GtkWidget *bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); + gtk_widget_set_margin_start(bar, 14); + gtk_widget_set_margin_end(bar, 14); + gtk_widget_set_margin_top(bar, 12); + gtk_widget_set_margin_bottom(bar, 12); + gtk_box_append(GTK_BOX(column), bar); + e->heading = gtk_label_new(e->key); + gtk_widget_add_css_class(e->heading, "title-3"); + gtk_box_append(GTK_BOX(bar), e->heading); + e->version = gtk_label_new(""); + gtk_widget_add_css_class(e->version, "dim-label"); + gtk_box_append(GTK_BOX(bar), e->version); + GtkWidget *spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(spacer, TRUE); + gtk_box_append(GTK_BOX(bar), spacer); + GtkWidget *definition = gtk_button_new_with_label("Definition · Ctrl+Shift+D"); + g_signal_connect(definition, "clicked", G_CALLBACK(toggle_definition_clicked), e); + gtk_box_append(GTK_BOX(bar), definition); + GtkWidget *reload = gtk_button_new_with_label("Reload"); + g_signal_connect(reload, "clicked", G_CALLBACK(reload_clicked), e); + gtk_box_append(GTK_BOX(bar), reload); + GtkWidget *discard = gtk_button_new_with_label("Discard & reload"); + g_signal_connect(discard, "clicked", G_CALLBACK(discard_clicked), e); + gtk_box_append(GTK_BOX(bar), discard); + GtkWidget *save = gtk_button_new_with_label("Save · Ctrl+S"); + gtk_widget_add_css_class(save, "suggested-action"); + g_signal_connect(save, "clicked", G_CALLBACK(save_clicked), e); + gtk_box_append(GTK_BOX(bar), save); + GtkWidget *scroll = gtk_scrolled_window_new(); + gtk_widget_set_vexpand(scroll, TRUE); + gtk_box_append(GTK_BOX(column), scroll); + e->text = gtk_text_view_new(); + gtk_accessible_update_property(GTK_ACCESSIBLE(e->text), + GTK_ACCESSIBLE_PROPERTY_LABEL, "Document text", -1); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(e->text), TRUE); + gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(e->text), GTK_WRAP_WORD_CHAR); + gtk_widget_set_margin_start(e->text, 18); + gtk_widget_set_margin_end(e->text, 18); + gtk_widget_set_margin_top(e->text, 12); + gtk_widget_set_margin_bottom(e->text, 12); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), e->text); + e->source_revealer = gtk_revealer_new(); + gtk_box_append(GTK_BOX(column), e->source_revealer); + GtkWidget *source_panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_start(source_panel, 14); + gtk_widget_set_margin_end(source_panel, 14); + gtk_widget_set_margin_bottom(source_panel, 8); + gtk_revealer_set_child(GTK_REVEALER(e->source_revealer), source_panel); + GtkWidget *source_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_box_append(GTK_BOX(source_panel), source_bar); + GtkWidget *source_heading = gtk_label_new("Application definition · editor.pure"); + gtk_widget_add_css_class(source_heading, "title-4"); + gtk_box_append(GTK_BOX(source_bar), source_heading); + GtkWidget *source_spacer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0); + gtk_widget_set_hexpand(source_spacer, TRUE); + gtk_box_append(GTK_BOX(source_bar), source_spacer); + GtkWidget *source_reload = gtk_button_new_with_label("Reload definition"); + g_signal_connect(source_reload, "clicked", G_CALLBACK(reload_definition_clicked), e); + gtk_box_append(GTK_BOX(source_bar), source_reload); + GtkWidget *source_discard = gtk_button_new_with_label("Discard & reload"); + g_signal_connect(source_discard, "clicked", G_CALLBACK(discard_definition_clicked), e); + gtk_box_append(GTK_BOX(source_bar), source_discard); + GtkWidget *source_apply = gtk_button_new_with_label("Apply & save · Ctrl+S"); + gtk_widget_add_css_class(source_apply, "suggested-action"); + g_signal_connect(source_apply, "clicked", G_CALLBACK(apply_definition_clicked), e); + gtk_box_append(GTK_BOX(source_bar), source_apply); + GtkWidget *source_scroll = gtk_scrolled_window_new(); + gtk_widget_set_size_request(source_scroll, -1, 260); + gtk_box_append(GTK_BOX(source_panel), source_scroll); + e->source = gtk_text_view_new(); + gtk_accessible_update_property(GTK_ACCESSIBLE(e->source), + GTK_ACCESSIBLE_PROPERTY_LABEL, "Application definition source", -1); + gtk_text_view_set_monospace(GTK_TEXT_VIEW(e->source), TRUE); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(source_scroll), e->source); + GtkTextBuffer *source_buffer = gtk_text_view_get_buffer(GTK_TEXT_VIEW(e->source)); + gtk_text_buffer_set_text(source_buffer, e->definition_baseline, + (int)e->definition_baseline_size); + gtk_text_buffer_set_modified(source_buffer, FALSE); + e->source_status = gtk_label_new("Definition is active; edit and apply to change it"); + gtk_label_set_xalign(GTK_LABEL(e->source_status), 0); + gtk_box_append(GTK_BOX(source_panel), e->source_status); + GtkWidget *footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + gtk_widget_set_margin_start(footer, 14); + gtk_widget_set_margin_end(footer, 14); + gtk_widget_set_margin_top(footer, 9); + gtk_widget_set_margin_bottom(footer, 11); + gtk_box_append(GTK_BOX(column), footer); + e->status = gtk_label_new(""); + gtk_label_set_xalign(GTK_LABEL(e->status), 0); + gtk_widget_set_hexpand(e->status, TRUE); + gtk_box_append(GTK_BOX(footer), e->status); + e->key_entry = gtk_entry_new(); + gtk_entry_set_max_length(GTK_ENTRY(e->key_entry), 64); + gtk_editable_set_text(GTK_EDITABLE(e->key_entry), e->key); + gtk_entry_set_placeholder_text(GTK_ENTRY(e->key_entry), "Document key"); + gtk_editable_set_width_chars(GTK_EDITABLE(e->key_entry), 15); + gtk_accessible_update_property(GTK_ACCESSIBLE(e->key_entry), + GTK_ACCESSIBLE_PROPERTY_LABEL, "Document key", -1); + g_signal_connect(e->key_entry, "activate", G_CALLBACK(open_key_activated), e); + gtk_box_append(GTK_BOX(footer), e->key_entry); + GtkWidget *open = gtk_button_new_with_label("Open · Ctrl+O"); + g_signal_connect(open, "clicked", G_CALLBACK(open_key_clicked), e); + gtk_box_append(GTK_BOX(footer), open); + GtkEventController *keys = gtk_event_controller_key_new(); + g_signal_connect(keys, "key-pressed", G_CALLBACK(key_pressed), e); + gtk_widget_add_controller(e->window, keys); + open_document(e); + gtk_window_present(GTK_WINDOW(e->window)); +} + +int main(int argc, char **argv) { + if (argc != 6 || strcmp(argv[1], "--program") || strcmp(argv[3], "--store") || + !valid_key(argv[5])) { + fputs("pure-edit-window --program FILE --store DIRECTORY KEY\n", stderr); + return 2; + } + Editor e = {.directory = argv[4], .program_path = argv[2], .definition_generation = 1}; + memcpy(e.key, argv[5], strlen(argv[5]) + 1); + char error[160] = {0}; + gsize source_size = 0; + GError *source_error = NULL; + if (!g_file_get_contents(e.program_path, &e.definition_baseline, &source_size, &source_error) || + source_size > PURE_PROGRAM_LIMIT || + memchr(e.definition_baseline, 0, source_size) || + !g_utf8_validate(e.definition_baseline, (gssize)source_size, NULL) || + !pure_program_parse(e.definition_baseline, source_size, &e.program, error, sizeof(error)) || + !edit_layout(&e.program, &e.prefix)) { + fprintf(stderr, "Program must define the simple splice edit layout: %s\n", + error[0] ? error : source_error ? source_error->message : "unsupported layout"); + if (source_error) g_error_free(source_error); + g_free(e.definition_baseline); + return 2; + } + e.definition_baseline_size = source_size; + if (pure_store_init(e.directory) != PURE_OK || + !pure_runtime_init(&e.runtime, e.work, 1, &e.program, + pure_store_host(e.directory), 4u * 1024u * 1024u)) { + fputs("Cannot initialize document store or execution domain\n", stderr); + return 2; + } + e.baseline = g_strdup(""); + GtkApplication *application = gtk_application_new("org.pure.DocumentEditor", G_APPLICATION_NON_UNIQUE); + g_signal_connect(application, "activate", G_CALLBACK(activate), &e); + char *app_argv[] = {argv[0], NULL}; + int result = g_application_run(G_APPLICATION(application), 1, app_argv); + g_object_unref(application); + g_free(e.baseline); + g_free(e.definition_baseline); + return result; +} diff --git a/prototypes/connections/edit.c b/prototypes/connections/edit.c new file mode 100644 index 0000000..7c070e4 --- /dev/null +++ b/prototypes/connections/edit.c @@ -0,0 +1,177 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#ifdef PURE_EMBEDDED_PROGRAM +#include "embedded-plan.h" +#endif + +/* Native automation entry for the same prepared document program as the + * network adapter. No socket or body serialization in the local execution. */ +static bool number(const char *text, uint64_t limit, uint64_t *out) { + if (!*text) return false; + uint64_t value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > limit || value > (limit - digit) / 10) return false; + value = value * 10 + digit; + } + *out = value; + return true; +} + +static const char *message(PureResult status) { + switch (status) { + case PURE_OK: return "ok"; + case PURE_NOT_FOUND: return "not_found"; + case PURE_CONFLICT: return "conflict"; + case PURE_BUSY: return "busy"; + case PURE_BUDGET: return "payload_budget"; + case PURE_LIMIT: return "limit"; + case PURE_UNCERTAIN: return "durability_unknown"; + case PURE_DENIED: return "denied"; + case PURE_ALLOCATION: return "allocation"; + case PURE_INVALID: return "invalid"; + default: return "failed"; + } +} + +static void put32(unsigned char *to, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) to[i] = (unsigned char)(value >> (8 * i)); +} + +/* The convenience command fills the program's two numeric splice arguments + * and its trailing insertion bytes. A more general input uses apply. */ +static bool edit_layout(const PureProgram *p, size_t *prefix) { + if (!p->splice || !p->write.to_end || p->write_step || p->input_steps || + !p->splice_offset.from_u32le || !p->splice_remove.from_u32le) return false; + size_t start = p->splice_offset.value, remove = p->splice_remove.value; + *prefix = p->write.offset; + if (*prefix > PURE_OBJECT_LIMIT || p->input_required > *prefix || start > *prefix || + remove > *prefix || *prefix - start < 4 || *prefix - remove < 4) return false; + return start < remove ? remove - start >= 4 : start - remove >= 4; +} + +int main(int argc, char **argv) { +#ifndef PURE_EMBEDDED_PROGRAM + const char *program_path = NULL, *store_path = NULL; +#else + const char *store_path = NULL; +#endif + size_t budget = 4u * 1024u * 1024u; + int at = 1; + for (; at + 1 < argc && !strncmp(argv[at], "--", 2); at += 2) { + uint64_t value; + if (!strcmp(argv[at], "--store")) store_path = argv[at + 1]; +#ifndef PURE_EMBEDDED_PROGRAM + else if (!strcmp(argv[at], "--program")) program_path = argv[at + 1]; +#endif + else if (!strcmp(argv[at], "--budget") && number(argv[at + 1], SIZE_MAX, &value)) budget = (size_t)value; + else goto usage; + } +#ifndef PURE_EMBEDDED_PROGRAM + if (!program_path) goto usage; +#endif + if (!store_path || at >= argc) goto usage; + bool show = !strcmp(argv[at], "show"), edit = !strcmp(argv[at], "edit"); + if ((!show && !edit && strcmp(argv[at], "apply")) || + argc - at != (show ? 2 : edit ? 6 : 4)) goto usage; + uint64_t expected = 0, offset = 0, remove = 0; + if (!show && !number(argv[at + 2], INT64_MAX, &expected)) goto usage; + if (edit && (!number(argv[at + 3], UINT32_MAX, &offset) || !number(argv[at + 4], UINT32_MAX, &remove))) goto usage; +#ifndef PURE_EMBEDDED_PROGRAM + PureProgram loaded; + char error[160]; + if (!pure_program_load(program_path, &loaded, error, sizeof(error))) { + fprintf(stderr, "Program: %s\n", error); return 2; + } + const PureProgram *program = &loaded; +#else + const PureProgram *program = &pure_embedded_program; +#endif + size_t prefix = 0; + if (edit && !edit_layout(program, &prefix)) { + fputs("edit requires two distinct u32le splice fields and trailing insertion bytes; use apply for a custom input\n", stderr); + return 2; + } + FILE *file = NULL; + size_t length = 0; + if (!show) { + file = fopen(argv[at + (edit ? 5 : 3)], "rb"); + if (!file) { perror("input"); return 2; } + long bytes; + if (fseek(file, 0, SEEK_END) || (bytes = ftell(file)) < 0 || + (uintmax_t)bytes > PURE_OBJECT_LIMIT - prefix || fseek(file, 0, SEEK_SET)) { + fputs("input must be a seekable file within the request size limit\n", stderr); + fclose(file); return 2; + } + length = (size_t)bytes; + } + if (pure_store_init(store_path) != PURE_OK) { + if (file) fclose(file); + fputs("cannot initialize store\n", stderr); return 2; + } + PureRuntime runtime; + PureWork work[1]; + if (!pure_runtime_init(&runtime, work, 1, program, pure_store_host(store_path), budget)) { + if (file) fclose(file); + return 2; + } + PureTicket ticket; + PureResult admitted = pure_runtime_accept(&runtime, show ? PURE_INPUT_READ : PURE_INPUT_WRITE, + argv[at + 1], (int64_t)expected, prefix + length, &ticket); + if (admitted != PURE_OK) { + if (file) fclose(file); + fprintf(stderr, "%s\n", message(admitted)); return 2; + } + if (!show) { + unsigned char *input = pure_runtime_ingress(ticket, NULL); + if (prefix) memset(input, 0, prefix); + if (edit) { + put32(input + program->splice_offset.value, (uint32_t)offset); + put32(input + program->splice_remove.value, (uint32_t)remove); + } + bool valid = !length || fread(input + prefix, 1, length, file) == length; + if (fgetc(file) != EOF || ferror(file)) valid = false; + if (fclose(file)) valid = false; + if (!valid) { + pure_runtime_release(ticket); + fputs("input changed or could not be read; nothing was applied\n", stderr); return 2; + } + if (!pure_runtime_ready(ticket)) { pure_runtime_release(ticket); return 2; } + } + PureOutcome outcome; + while (!pure_runtime_result(ticket, &outcome)) { + if (!pure_runtime_step(&runtime)) { pure_runtime_release(ticket); return 2; } + } + int status = 0; + if (outcome.status != PURE_OK) { + fprintf(stderr, "{\"error\":\"%s\",\"revision\":\"%" PRId64 "\"}\n", message(outcome.status), outcome.revision); + status = outcome.status == PURE_CONFLICT ? 3 : 2; + } else if (show) { + if (outcome.body.size && fwrite(outcome.body.data, 1, outcome.body.size, stdout) != outcome.body.size) status = 2; + fprintf(stderr, "{\"revision\":\"%" PRId64 "\",\"bytes\":%zu}\n", outcome.revision, outcome.body.size); + } else { + if (printf("{\"revision\":\"%" PRId64 "\",\"reply_bytes\":%zu}\n", outcome.revision, outcome.body.size) < 0) status = 2; + } + if (fflush(stdout)) status = 2; + pure_runtime_release(ticket); + return status; +usage: + fputs( +#ifdef PURE_EMBEDDED_PROGRAM + "pure-edit-fixed --store DIRECTORY [--budget BYTES] show KEY\n" + "pure-edit-fixed --store DIRECTORY [--budget BYTES] edit KEY REVISION BYTE_OFFSET REMOVE_BYTES INSERT_FILE\n" + "pure-edit-fixed --store DIRECTORY [--budget BYTES] apply KEY REVISION INPUT_FILE\n" +#else + "pure-edit --program FILE --store DIRECTORY [--budget BYTES] show KEY\n" + "pure-edit --program FILE --store DIRECTORY [--budget BYTES] edit KEY REVISION BYTE_OFFSET REMOVE_BYTES INSERT_FILE\n" + "pure-edit --program FILE --store DIRECTORY [--budget BYTES] apply KEY REVISION INPUT_FILE\n" +#endif + "edit uses the prepared program's field locations; apply accepts its raw input bytes.\n", stderr); + return 2; +} diff --git a/prototypes/connections/evaluate.c b/prototypes/connections/evaluate.c new file mode 100644 index 0000000..88ffae7 --- /dev/null +++ b/prototypes/connections/evaluate.c @@ -0,0 +1,53 @@ +#include "program.h" +#include + +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/connections/examples/bounded-editor.pure b/prototypes/connections/examples/bounded-editor.pure new file mode 100644 index 0000000..e9f2634 --- /dev/null +++ b/prototypes/connections/examples/bounded-editor.pure @@ -0,0 +1,19 @@ +// Same document edit as editor.pure, with an authored final-size policy. +// The store checks the limit against the current version under its writer lock. +layout Edit { + start: u32le; + remove: u32le; + insert: bytes; +} + +on get { + let document = store.read(request.key); + reply(document, document.body); +} + +on put { + let edit = view(Edit, request.body); + let saved = store.splice(request.key, request.revision, + edit.start, edit.remove, edit.insert, 8); + reply(saved, edit.insert); +} diff --git a/prototypes/connections/examples/editor.pure b/prototypes/connections/examples/editor.pure new file mode 100644 index 0000000..4de78bc --- /dev/null +++ b/prototypes/connections/examples/editor.pure @@ -0,0 +1,19 @@ +// A revision-checked byte edit. The input carries only the replacement. +// UTF-8 cursor/grapheme positions are translated to byte ranges by the UI. +layout Edit { + start: u32le; + remove: u32le; + insert: bytes; +} + +on get { + let document = store.read(request.key); + reply(document, document.body); +} + +on put { + let edit = view(Edit, request.body); + let saved = store.splice(request.key, request.revision, + edit.start, edit.remove, edit.insert); + reply(saved, edit.insert); +} diff --git a/prototypes/connections/examples/excerpt.pure b/prototypes/connections/examples/excerpt.pure new file mode 100644 index 0000000..622f9bb --- /dev/null +++ b/prototypes/connections/examples/excerpt.pure @@ -0,0 +1,13 @@ +// Read bytes 32..47 of an object that must contain the complete 64-byte window. +// Preparation keeps the size check for 16..79, and maps only 32..47 for reply. +on get { + let object = store.read(request.key); + let window = range(object.body, 16, 64); + let excerpt = range(window, 16, 16); + reply(object, excerpt); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/examples/select-text.py b/prototypes/connections/examples/select-text.py new file mode 100644 index 0000000..ffaf80b --- /dev/null +++ b/prototypes/connections/examples/select-text.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Save a UTF-8 document selection through selection.pure (character indices).""" +import argparse +import http.client +import json +from pathlib import Path +import re +import struct +from urllib.parse import urlsplit + +LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('file', type=Path) + parser.add_argument('--key', required=True) + parser.add_argument('--start', type=int, required=True, help='first Unicode code point, starting at 0') + parser.add_argument('--end', type=int, required=True, help='exclusive Unicode code point boundary') + parser.add_argument('--revision', type=int, default=0, help='0 creates; otherwise expected stored revision') + parser.add_argument('--url', default='http://127.0.0.1:8767') + args = parser.parse_args() + url = urlsplit(args.url) + try: + port = url.port or 80 + except ValueError as error: + parser.error(str(error)) + if (url.scheme != 'http' or url.hostname not in ['localhost', '127.0.0.1'] or + url.username is not None or url.password is not None or url.path not in ['', '/'] or + url.query or url.fragment): + parser.error('use a local HTTP server origin') + if not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', args.key) or not 0 <= args.revision <= 2**63 - 1: + parser.error('invalid object key or revision') + try: + with args.file.open('rb') as source: + document = source.read(LIMIT - 8 + 1) + if len(document) > LIMIT - 8: + parser.error('document exceeds the prototype request limit') + text = document.decode('utf-8') + except (OSError, UnicodeError) as error: + parser.error(str(error)) + if not 0 <= args.start <= args.end <= len(text): + parser.error('selection is outside the document') + start = len(text[:args.start].encode('utf-8')) + selected = text[args.start:args.end].encode('utf-8') + # The boundary layout is explicit; storage receives only selected bytes. + payload = struct.pack(' 2**63 - 1: + parser.error('response has no valid stored revision (write may have occurred)') + except (OSError, http.client.HTTPException) as error: + parser.error(f'{error}; storage outcome is unknown if the request was sent') + finally: + connection.close() + print(json.dumps({'revision': revision[1:-1], 'selected': selected.decode('utf-8'), + 'stored_bytes': len(selected)}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/examples/selection.pure b/prototypes/connections/examples/selection.pure new file mode 100644 index 0000000..7d9d4ec --- /dev/null +++ b/prototypes/connections/examples/selection.pure @@ -0,0 +1,19 @@ +// This is an input boundary layout, not a required storage or RAM model. +// The client sends two little-endian u32 values followed by document bytes. +layout Selection { + start: u32le; + count: u32le; + document: bytes; +} + +on get { + let saved = store.read(request.key); + reply(saved, saved.body); +} + +on put { + let input = view(Selection, request.body); + let selected = range(input.document, input.start, input.count); + let saved = store.write(request.key, request.revision, selected); + reply(saved, selected); +} diff --git a/prototypes/connections/examples/storage.pure b/prototypes/connections/examples/storage.pure new file mode 100644 index 0000000..ff54ea3 --- /dev/null +++ b/prototypes/connections/examples/storage.pure @@ -0,0 +1,11 @@ +// The host supplies request bytes, an object name, and the expected revision. +// Failed storage results propagate to the host's response without executing reply. +on get { + let object = store.read(request.key); + reply(object, object.body); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/export_flat_store.py b/prototypes/connections/export_flat_store.py new file mode 100644 index 0000000..9469d5a --- /dev/null +++ b/prototypes/connections/export_flat_store.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Export the selected paged document store to a new default-format store.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +KEY = re.compile(r'[A-Za-z0-9_-]{1,64}\Z') +LIMIT = (2 * 6 + 32) * 32768 + 8192 +FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def show(binary, store, key): + result = subprocess.run([str(binary), '--store', str(store), 'show', key], + capture_output=True, timeout=30) + if result.returncode: + raise RuntimeError(f'{binary.name} could not read {key}: {result.stderr[-400:]!r}') + details = json.loads(result.stderr) + revision = int(details['revision']) + body = result.stdout + if not 1 <= revision <= (1 << 63) - 1 or len(body) != details['bytes'] or len(body) > LIMIT: + raise RuntimeError(f'invalid stored document: {key}') + return revision, body + + +def write_all(fd, content): + content = memoryview(content) + while content: + written = os.write(fd, content) + if written <= 0: + raise OSError('short store write') + content = content[written:] + + +def export(source, destination, paged_editor, flat_editor): + source = source.resolve(strict=True) + if os.path.lexists(destination): + raise ValueError('destination must not exist, including a symlink') + destination = destination.resolve(strict=False) + if (not source.is_dir() or source == destination or source in destination.parents or + os.path.lexists(destination)): + raise ValueError('source must be a directory and destination must be a new separate path') + parent = destination.parent.resolve(strict=True) + if not parent.is_dir(): + raise ValueError('destination parent must already exist') + lock = os.open(source / '.writer.lock', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) + stage = None + try: + # A separate CLI process reads each object; its lock-file close cannot + # release this parent's POSIX read lock. Cooperating writers get BUSY. + try: + fcntl.lockf(lock, fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError as error: + raise RuntimeError('source has an active writer; retry export') from error + keys = [] + with os.scandir(source) as entries: + for entry in entries: + if not entry.name.startswith('obj-'): + continue + key = entry.name[4:] + if not KEY.fullmatch(key) or not entry.is_file(follow_symlinks=False): + raise RuntimeError(f'invalid object entry: {entry.name}') + keys.append(key) + stage = Path(tempfile.mkdtemp(prefix=f'.{destination.name}.export-', dir=parent)) + stage_fd = os.open(stage, FLAGS) + digest = hashlib.sha256() + total = 0 + try: + marker = os.open('.writer.lock', os.O_CREAT | os.O_EXCL | os.O_WRONLY | + os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=stage_fd) + try: + os.fsync(marker) + finally: + os.close(marker) + for key in sorted(keys): + revision, body = show(paged_editor, source, key) + name = 'obj-' + key + fd = os.open(name, os.O_CREAT | os.O_EXCL | os.O_WRONLY | + os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=stage_fd) + try: + header = b'PUREB001' + revision.to_bytes(8, 'little') + len(body).to_bytes(8, 'little') + write_all(fd, header) + write_all(fd, body) + os.fsync(fd) + finally: + os.close(fd) + if show(flat_editor, stage, key) != (revision, body): + raise RuntimeError(f'flat readback differs: {key}') + digest.update(key.encode('ascii') + b'\0') + digest.update(header[8:]) + digest.update(body) + total += len(body) + os.fsync(stage_fd) + finally: + os.close(stage_fd) + if os.path.lexists(destination): + raise FileExistsError(destination) + os.rename(stage, destination) + stage = None + parent_fd = os.open(parent, FLAGS) + try: + os.fsync(parent_fd) + finally: + os.close(parent_fd) + return {'objects': len(keys), 'logical_bytes': total, + 'document_digest': digest.hexdigest(), + 'paged_reader_sha256': hashlib.sha256(paged_editor.read_bytes()).hexdigest(), + 'flat_reader_sha256': hashlib.sha256(flat_editor.read_bytes()).hexdigest()} + finally: + if stage is not None: + shutil.rmtree(stage) + os.close(lock) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, required=True) + parser.add_argument('--destination', type=Path, required=True) + parser.add_argument('--paged-editor', type=Path, required=True, + help='fixed editor linked with store-paged.c') + parser.add_argument('--flat-editor', type=Path, required=True, + help='fixed editor linked with the default store.c') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + result = export(args.source, args.destination, + args.paged_editor.resolve(strict=True), args.flat_editor.resolve(strict=True)) + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/fixed-budget.json b/prototypes/connections/fixed-budget.json new file mode 100644 index 0000000..35439be --- /dev/null +++ b/prototypes/connections/fixed-budget.json @@ -0,0 +1,18 @@ +{ + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "binaries": { + "pure-edit-fixed": { + "max_text_bytes": 20480, + "max_data_bytes": 1000, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + }, + "pure-connections-fixed": { + "max_text_bytes": 26880, + "max_data_bytes": 1100, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The HTTP ceiling includes the reviewed 188-byte cost of moving document interpretation from the reusable core into the selected document adapter and the later 64-byte linked increase for an authored final-size guard. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files." +} diff --git a/prototypes/connections/fixed-package-budget.json b/prototypes/connections/fixed-package-budget.json new file mode 100644 index 0000000..2ae8671 --- /dev/null +++ b/prototypes/connections/fixed-package-budget.json @@ -0,0 +1,10 @@ +{ + "profile": "linux-aarch64-gcc14-fixed-document-package", + "max_archive_bytes": { + "cli": 16384, + "cli_http": 34816, + "cli_tui": 35840, + "cli_http_tui": 54272 + }, + "scope": "Deterministic gzip archive containing selected stripped AArch64 binaries, manifest and usage text. The combined CLI+HTTP+TUI ceiling was reviewed upward by 1 KiB for the bounded document browser; the CLI-only ceiling is unchanged. These are regression ceilings for the current document app and toolchain; they exclude host libc, terminal emulator, kernel buffers and stored documents. Optional hosts are charged to their selected package, not the CLI-only package." +} diff --git a/prototypes/connections/flow.c b/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/flow.h b/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/hybrid-budget.json b/prototypes/connections/hybrid-budget.json new file mode 100644 index 0000000..e36b9e7 --- /dev/null +++ b/prototypes/connections/hybrid-budget.json @@ -0,0 +1,18 @@ +{ + "profile": "linux-aarch64-gcc14-selected-hybrid-document-storage", + "binaries": { + "pure-edit": { + "max_text_bytes": 43008, + "max_data_bytes": 1200, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + }, + "pure-connections": { + "max_text_bytes": 50176, + "max_data_bytes": 1300, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Opt-in inline/PUREP001 document store on Linux AArch64/GCC 14. The CLI text ceiling is 42 KiB. The HTTP ceiling is 49 KiB after reviewing its 49133-to-49589-byte growth for the authored final-size guard, which checks the locked document version. This is a selected research budget, not the default micro-runtime budget. Direct dependencies only; excludes libc, process RAM, filesystem cache and journal, optional TLS/GTK." +} diff --git a/prototypes/connections/hybrid-tui-budget.json b/prototypes/connections/hybrid-tui-budget.json new file mode 100644 index 0000000..a5d37f2 --- /dev/null +++ b/prototypes/connections/hybrid-tui-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-hybrid-source-document-terminal-editor", + "binaries": { + "pure-edit-tui": { + "max_text_bytes": 55296, + "max_data_bytes": 1152, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Selected source-loaded native document terminal editor with the existing 16 KiB hybrid page store. This ceiling reviews the measured 53,642-byte linked text build on Linux AArch64/GCC 14. It excludes libc, process RSS, mapped document pages and filesystem cache; check_tui.py separately gates actual one-MiB and 1,024-document work. The default core, CLI and TUI do not select this store." +} diff --git a/prototypes/connections/measure-peak-rss.c b/prototypes/connections/measure-peak-rss.c new file mode 100644 index 0000000..1fe2e0b --- /dev/null +++ b/prototypes/connections/measure-peak-rss.c @@ -0,0 +1,43 @@ +#ifdef __APPLE__ +#define _DARWIN_C_SOURCE +#else +#define _DEFAULT_SOURCE +#endif +#include +#include +#include +#include +#include + +/* Test helper: fork from a small native process so the interpreter's pre-exec + * RSS cannot become the measured child's peak. Not shipped. */ +int main(int argc, char **argv) { + if (argc < 2) return 2; + pid_t child = fork(); + if (child < 0) return 2; + if (!child) { + int null = open("/dev/null", O_WRONLY); + if (null < 0 || dup2(null, STDOUT_FILENO) < 0 || + dup2(null, STDERR_FILENO) < 0) _exit(127); + close(null); + execvp(argv[1], argv + 1); + _exit(127); + } + struct rusage usage; + int status; + if (wait4(child, &status, 0, &usage) < 0) return 2; + if (!WIFEXITED(status)) return 2; + if (usage.ru_maxrss < 0) return 2; +#ifdef __APPLE__ + /* XNU reports ru_maxrss in bytes; Linux reports KiB. */ + long peak_kib = usage.ru_maxrss / 1024 + (usage.ru_maxrss % 1024 != 0); + const char *raw_unit = "bytes"; +#else + long peak_kib = usage.ru_maxrss; + const char *raw_unit = "KiB"; +#endif + printf("{\"exit_code\":%d,\"max_rss_kib\":%ld," + "\"raw_max_rss\":%ld,\"raw_unit\":\"%s\"}\n", + WEXITSTATUS(status), peak_kib, usage.ru_maxrss, raw_unit); + return 0; +} diff --git a/prototypes/connections/measure_tui_latency.py b/prototypes/connections/measure_tui_latency.py new file mode 100644 index 0000000..569284b --- /dev/null +++ b/prototypes/connections/measure_tui_latency.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Measure one visible PTY redraw per edit in the real fixed document app.""" +import argparse +import fcntl +import hashlib +import json +import os +from pathlib import Path +import platform +import pty +import select +import statistics +import struct +import subprocess +import tempfile +import termios +import time + +HERE = Path(__file__).resolve().parent +WORKLOADS = { + '256-byte-ascii': b'Q' * 256, + 'one-mib-ascii': b'Q' * 1_048_576, + 'one-mib-utf8': '안녕'.encode() * 174_762, +} + + +def frame(fd, process): + data = bytearray() + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if select.select([fd], [], [], 0.5)[0]: + data.extend(os.read(fd, 65536)) + if b'\x1b[?25h' in data: + return bytes(data) + if process.poll() is not None: + break + raise RuntimeError(f'editor did not finish a frame: exit={process.poll()}, output={data[-300:]!r}') + + +def measure(tui, cli, store, root, name, body): + insert = root / f'{name}.insert' + insert.write_bytes(body) + subprocess.run([cli, '--store', store, 'edit', name, '0', '0', '0', insert], + check=True, capture_output=True, timeout=15) + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + process = subprocess.Popen([tui, '--store', store, name], stdin=slave, + stdout=slave, stderr=slave, + env={**os.environ, 'LANG': 'C.UTF-8'}) + try: + frame(master, process) + samples = [] + for _ in range(10): + start = time.perf_counter_ns() + os.write(master, b'Z') + shown = frame(master, process) + samples.append({'nanoseconds': time.perf_counter_ns() - start, + 'frame_bytes': len(shown), + 'frame_sha256': hashlib.sha256(shown).hexdigest()}) + os.write(master, b'\x11') + frame(master, process) + os.write(master, b'\x11') + process.wait(timeout=5) + if process.returncode: + raise RuntimeError(f'editor exit={process.returncode}') + return {'document_bytes': len(body), 'samples': samples, + 'median_nanoseconds': int(statistics.median(s['nanoseconds'] for s in samples))} + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + os.close(master) + os.close(slave) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--tui', type=Path, required=True) + parser.add_argument('--cli', type=Path, default=HERE / 'build/pure-edit-fixed') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + tui, cli = args.tui.resolve(), args.cli.resolve() + with tempfile.TemporaryDirectory(prefix='pure-tui-latency-') as directory: + root = Path(directory) + workloads = {name: measure(tui, cli, root / 'store', root, name, body) + for name, body in WORKLOADS.items()} + result = {'platform': platform.platform(), 'terminal': 'PTY 24x80, C.UTF-8', + 'tui_sha256': hashlib.sha256(tui.read_bytes()).hexdigest(), + 'cli_sha256': hashlib.sha256(cli.read_bytes()).hexdigest(), + 'workloads': workloads} + text = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text) + print(text, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/memory-budget.json b/prototypes/connections/memory-budget.json new file mode 100644 index 0000000..320e011 --- /dev/null +++ b/prototypes/connections/memory-budget.json @@ -0,0 +1,7 @@ +{ + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "max_small_edit_rss_kib": 4096, + "max_one_mib_edit_rss_kib": 4096, + "max_one_mib_show_rss_kib": 4096, + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee." +} diff --git a/prototypes/connections/mirror.c b/prototypes/connections/mirror.c new file mode 100644 index 0000000..34ff1bf --- /dev/null +++ b/prototypes/connections/mirror.c @@ -0,0 +1,145 @@ +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include "storage-plan.h" + +enum { MAX_TARGETS = 8 }; +typedef struct { + PureRuntime runtime; + PureWork work[1]; + PureTicket ticket; + PureOutcome outcome; +} Target; + +static const char *result_name(PureResult result) { + static const char *const names[] = { + "ok", "not_found", "conflict", "busy", "budget", "limit", + "store_error", "durability_unknown", "invalid", "denied", + "closed", "cancelled", "allocation" + }; + return (unsigned)result < sizeof(names) / sizeof(names[0]) ? names[result] : "unknown"; +} + +static bool number(const char *text, uint64_t limit, uint64_t *out) { + uint64_t value = 0; + if (!*text) return false; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > limit || value > (limit - digit) / 10) return false; + value = value * 10 + digit; + } + *out = value; + return true; +} + +int main(int argc, char **argv) { + int at = 1; + size_t target_budget = PURE_OBJECT_LIMIT; + uint64_t parsed; + if (argc > 2 && !strcmp(argv[1], "--target-budget")) { + if (!number(argv[2], SIZE_MAX, &parsed)) return 2; + target_budget = (size_t)parsed; + at += 2; + } + if (argc - at < 5 || (argc - at - 2) % 3 || + (argc - at - 2) / 3 > MAX_TARGETS) { + fputs("usage: pure-mirror [--target-budget BYTES] SOURCE_STORE SOURCE_KEY TARGET_STORE TARGET_KEY EXPECTED_REVISION [TARGET_STORE TARGET_KEY EXPECTED_REVISION ...]\n", stderr); + return 2; + } + size_t count = (size_t)(argc - at - 2) / 3; + int64_t expected[MAX_TARGETS]; + for (size_t i = 0; i < count; ++i) { + if (!number(argv[at + 4 + i * 3], INT64_MAX, &parsed)) return 2; + expected[i] = (int64_t)parsed; + } + if (pure_store_init(argv[at]) != PURE_OK) return 2; + PureRuntime source; + PureWork source_work[1]; + if (!pure_runtime_init(&source, source_work, 1, &pure_embedded_program, + pure_store_host(argv[at]), PURE_OBJECT_LIMIT)) return 2; + PureTicket read; + PureResult status = pure_runtime_accept(&source, PURE_INPUT_READ, argv[at + 1], 0, 0, &read); + if (status != PURE_OK) { + printf("{\"source_status\":\"%s\"}\n", result_name(status)); + return 2; + } + PureOutcome snapshot; + if (!pure_runtime_step(&source) || !pure_runtime_result(read, &snapshot)) { + pure_runtime_release(read); + return 2; + } + if (snapshot.status != PURE_OK) { + printf("{\"source_status\":\"%s\"}\n", result_name(snapshot.status)); + pure_runtime_release(read); + return 2; + } + Target targets[MAX_TARGETS] = {0}; + size_t forwarded = 0; + for (size_t i = 0; i < count; ++i) { + const char *store = argv[at + 2 + i * 3], *key = argv[at + 3 + i * 3]; + PureHost host = pure_store_host(store); + host.allocate = NULL; /* Forwarded ingress uses the source mapping. */ + if (pure_store_init(store) != PURE_OK || + !pure_runtime_init(&targets[i].runtime, targets[i].work, 1, + &pure_embedded_program, host, + target_budget)) status = PURE_STORE_ERROR; + else status = pure_runtime_forward(read, &targets[i].runtime, key, + expected[i], &targets[i].ticket); + if (status != PURE_OK) { + for (size_t j = 0; j < forwarded; ++j) { + pure_runtime_cancel(targets[j].ticket); + pure_runtime_release(targets[j].ticket); + } + pure_runtime_release(read); + printf("{\"admission_status\":\"%s\",\"target_index\":%zu}\n", + result_name(status), i); + return 2; + } + ++forwarded; + } + /* Each queued target pins the mapped source snapshot independently. */ + pure_runtime_release(read); + if (snapshot.body.size && source.retained != snapshot.body.size) { + for (size_t i = 0; i < count; ++i) { + pure_runtime_cancel(targets[i].ticket); + pure_runtime_release(targets[i].ticket); + } + return 2; + } + int exit_status = 0; + for (size_t i = 0; i < count; ++i) { + if (!pure_runtime_step(&targets[i].runtime) || + !pure_runtime_result(targets[i].ticket, &targets[i].outcome)) { + for (size_t j = i; j < count; ++j) { + pure_runtime_cancel(targets[j].ticket); + pure_runtime_release(targets[j].ticket); + } + return 2; + } + if (targets[i].outcome.status == PURE_CONFLICT && !exit_status) exit_status = 3; + else if (targets[i].outcome.status != PURE_OK && + targets[i].outcome.status != PURE_CONFLICT) exit_status = 2; + pure_runtime_release(targets[i].ticket); + if (snapshot.body.size && i + 1 < count && + source.retained != snapshot.body.size) { + for (size_t j = i + 1; j < count; ++j) { + pure_runtime_cancel(targets[j].ticket); + pure_runtime_release(targets[j].ticket); + } + return 2; + } + } + if (source.active || source.retained) return 2; + printf("{\"source_revision\":\"%" PRId64 "\",\"bytes\":%zu,\"targets\":[", + snapshot.revision, snapshot.body.size); + for (size_t i = 0; i < count; ++i) + printf("%s{\"index\":%zu,\"status\":\"%s\",\"revision\":\"%" PRId64 "\"}", + i ? "," : "", i, result_name(targets[i].outcome.status), + targets[i].outcome.revision); + puts("]}"); + return fflush(stdout) ? 2 : exit_status; +} diff --git a/prototypes/connections/package_native.py b/prototypes/connections/package_native.py new file mode 100644 index 0000000..7ee9c4f --- /dev/null +++ b/prototypes/connections/package_native.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Bundle the selected Linux document app without folding hosts into the core.""" +import argparse +import gzip +import hashlib +import io +import json +from pathlib import Path +import platform +import re +import subprocess +import tarfile +import tempfile + +HERE = Path(__file__).resolve().parent +NEEDED = re.compile(r'\(NEEDED\).*Shared library: \[([^]]+)\]') +ROOT_NAME = 'pure-document' +PAGED_STORAGE = """# Selected page storage format + +This preview uses the PUREP001 16 KiB-page store. It cannot open PUREB001 +flat-store objects. Use a new --store directory; this is a research format, +not a migration or a default storage backend. Small objects currently pay a +full-page write cost. See docs/research/033-paged-document-storage.md in the +source repository for measured boundaries and limitations. +""" +HYBRID_STORAGE = """# Selected inline/page storage experiment + +Objects up to 16 KiB use the existing PUREB001 file format. Larger objects +use PUREP001 page roots and append-only data generations. The current flat +binary cannot read PUREP001 objects, so use a new --store directory for this +preview. This is a selected research build, not the default backend or a +bidirectional migration path. It requires a Linux host with 16 KiB OS pages. +See docs/research/034-hybrid-document-storage.md in the source repository. +""" + +RUN = """# Pure document preview — Linux native build + +This archive includes the CLI and editable editor.pure program. HTTP, TLS peer, +and GTK hosts are included only when listed in manifest.json. Shared system libraries are not bundled; +manifest.json records every binary's direct dependencies. + +From this directory, try a local edit: + + printf 'hello Pure\\n' > /tmp/pure-insert.txt + bin/pure-edit --program share/editor.pure --store /tmp/pure-preview-store \\ + edit note 0 0 0 /tmp/pure-insert.txt + bin/pure-edit --program share/editor.pure --store /tmp/pure-preview-store show note + +When selected, the same program and store can be opened with the GTK window: + + bin/pure-edit-window --program share/editor.pure \\ + --store /tmp/pure-preview-store note + +When selected, the source-loaded terminal editor uses the same program and +store without GTK. Edit share/editor.pure, then start a new TUI session to +load the changed definition: + + bin/pure-edit-tui --program share/editor.pure \\ + --store /tmp/pure-preview-store note + +When selected, the TLS peer needs your own CA and server/client certificates. The server +certificate needs a DNS SAN matching SERVER_DNS; the client certificate needs +a DNS SAN matching CLIENT_DNS. This example binds only to loopback: + + bin/pure-document-peer serve 127.0.0.1 9443 share/editor.pure \\ + /tmp/pure-preview-store CA.pem SERVER.pem SERVER.key CLIENT_DNS + bin/pure-document-peer call 127.0.0.1 9443 SERVER_DNS share/editor.pure \\ + CA.pem CLIENT.pem CLIENT.key show note + +If manifest.json lists pure-document-server instead, run: + + bin/pure-document-server serve 127.0.0.1 9443 share/editor.pure \\ + /tmp/pure-preview-store CA.pem SERVER.pem SERVER.key CLIENT_DNS + +That selection omits the native `call` command; connect with an authenticated +external client using the pure-doc/1 binary protocol. + +When selected, the HTTP host is a plaintext compatibility adapter; bind it only to loopback. +This preview was checked on Linux AArch64. It is not a static or macOS package. +""" +RUN_FIXED = """# Pure fixed document runner — Linux AArch64 + +This artifact runs one build-time prepared editor.pure definition. It has no +source parser, compiler, GTK or TLS host. Editing a .pure file does not +change this binary; rebuild it with `make fixed-editor` in the source tree. + + bin/pure-edit-fixed --store STORE edit KEY 0 0 0 INSERT_FILE + bin/pure-edit-fixed --store STORE show KEY + +When manifest.json lists pure-connections-fixed, it serves the same definition +and store through a loopback HTTP compatibility boundary: + + bin/pure-connections-fixed --store STORE --port 8767 + +When manifest.json lists pure-edit-tui-fixed, it opens a native terminal +editor for the same stored document. Ctrl+S saves, Ctrl+O enters another key, +Ctrl+L browses stored keys (type a prefix to jump), Ctrl+R reloads, and +Ctrl+Q quits: + + bin/pure-edit-tui-fixed --store STORE note + +The external storage files use the default PUREB001 format. The ELF binary +needs the host's libc and is not a static or macOS package. +""" + + +def dependencies(path): + header = subprocess.check_output(['readelf', '-h', str(path)], text=True) + if not re.search(r'Machine:\s+AArch64\s*$', header, re.MULTILINE): + raise ValueError(f'{path} is not an AArch64 ELF') + output = subprocess.check_output(['readelf', '-d', str(path)], text=True) + return sorted(NEEDED.findall(output)) + + +def reviewed_budget(build, name, selected=()): + command = ['python3', str(HERE / 'check_profile.py'), '--build', str(build), + '--budget', str(HERE / name)] + for binary in selected: + command.extend(['--only', binary]) + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode: + raise ValueError(f'{name} failed: {result.stdout or result.stderr}') + checked = json.loads(result.stdout) + return {'profile': checked['profile'], 'budget_sha256': checked['budget_sha256'], + 'checked_binaries': sorted(checked['binaries'])} + + +def reviewed_memory(build, fixed=False): + result = subprocess.run(['python3', str(HERE / 'check_micro_memory.py'), + '--build', str(build), *(['--fixed'] if fixed else [])], + capture_output=True, text=True) + if result.returncode: + raise ValueError(f'memory-budget.json failed: {result.stdout or result.stderr}') + checked = json.loads(result.stdout) + return {'profile': checked['profile'], 'budget_sha256': checked['budget_sha256']} + + +def reviewed_tui_memory(build, source=False): + result = subprocess.run(['python3', str(HERE / 'check_tui.py'), '--build', str(build), + *(['--source'] if source else [])], + capture_output=True, text=True) + if result.returncode: + raise ValueError(f'terminal editor check failed: {result.stdout or result.stderr}') + checked = json.loads(result.stdout) + return {'profile': checked['profile'], + 'one_mib_peak_rss_limit_kib': checked['one_mib_peak_rss_limit_kib'], + 'binary_sha256': checked['binary_sha256']} + + +def add_bytes(archive, name, content, mode): + info = tarfile.TarInfo(ROOT_NAME + '/' + name) + info.size = len(content) + info.mode = mode + info.mtime = 0 + info.uid = info.gid = 0 + info.uname = info.gname = '' + archive.addfile(info, io.BytesIO(content)) + + +def without_debug(source, compact=False): + # The release build retains symbols for local debugging; the runnable + # fixed package needs neither DWARF nor unused symbols. Never mutate the + # build used by size checks or source/authoring package symbols. + with tempfile.TemporaryDirectory(prefix='pure-package-strip-') as directory: + destination = Path(directory) / source.name + destination.write_bytes(source.read_bytes()) + subprocess.run(['strip', '--strip-unneeded' if compact else '--strip-debug', + str(destination)], check=True) + if dependencies(destination) != dependencies(source): + raise ValueError(f'{source}: stripping changed direct dependencies') + return destination.read_bytes() + + +def verify_storage(binary, mode): + with tempfile.TemporaryDirectory(prefix='pure-package-store-probe-') as directory: + root = Path(directory) + payload = root / 'insert' + payload.write_bytes(b'X') + result = subprocess.run([str(binary), '--program', str(HERE / 'examples/editor.pure'), + '--store', str(root / 'store'), 'edit', 'probe', '0', '0', '0', + str(payload)], capture_output=True, timeout=15) + expected = b'PUREB001' if mode == 'hybrid' else b'PUREP001' + if result.returncode or (root / 'store/obj-probe').read_bytes()[:8] != expected: + raise ValueError(f'{binary} is not a working {mode} CLI: {result.stderr[-300:]}') + if mode == 'hybrid': + payload.write_bytes(b'X' * (16 * 1024 + 1)) + result = subprocess.run([str(binary), '--program', str(HERE / 'examples/editor.pure'), + '--store', str(root / 'store'), 'edit', 'large', '0', '0', '0', + str(payload)], capture_output=True, timeout=15) + if result.returncode or (root / 'store/obj-large').read_bytes()[:8] != b'PUREP001': + raise ValueError(f'{binary} does not select PUREP001 for large objects') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, default=HERE / 'build') + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--with-http', action='store_true') + parser.add_argument('--with-tui', action='store_true') + peer = parser.add_mutually_exclusive_group() + peer.add_argument('--with-peer', action='store_true', + help='include the combined native TLS client/server') + peer.add_argument('--with-peer-server', action='store_true', + help='include only the authenticated TLS server') + parser.add_argument('--with-window', action='store_true') + parser.add_argument('--fixed', action='store_true', + help='package only the parser-free build-time prepared editor') + storage = parser.add_mutually_exclusive_group() + storage.add_argument('--paged-storage', action='store_true', + help='package the historical PUREP001-only storage backend') + storage.add_argument('--hybrid-storage', action='store_true', + help='package the selected PUREB001/PUREP001 storage experiment') + args = parser.parse_args() + if platform.system() != 'Linux' or platform.machine() != 'aarch64': + parser.error('this reviewed package profile is Linux AArch64 only') + if args.output.exists(): + parser.error('output already exists') + if args.fixed and (args.with_peer or args.with_peer_server or args.with_window or + args.paged_storage or args.hybrid_storage): + parser.error('--fixed selects only the flat-store CLI and optional HTTP/TUI hosts') + if args.with_tui and args.paged_storage: + parser.error('--with-tui supports the default or selected hybrid store') + if (args.with_peer or args.with_peer_server) and (args.paged_storage or args.hybrid_storage): + parser.error('the document TLS peer currently uses the default flat store') + if args.fixed: + built = subprocess.run(['make', '-C', str(HERE), f'BUILD={args.build.resolve()}', + 'FIXED_PROGRAM=examples/editor.pure', 'fixed-editor', + *(['fixed-server'] if args.with_http else []), + *(['fixed-tui'] if args.with_tui else [])], + capture_output=True, text=True) + if built.returncode: + parser.error(f'could not prepare the fixed editor: {built.stderr[-500:]}') + elif args.with_tui: + built = subprocess.run(['make', '-C', str(HERE), f'BUILD={args.build.resolve()}', + *(['STORE=store-paged.c'] if args.hybrid_storage else []), + 'editor', 'source-tui'], capture_output=True, text=True) + if built.returncode: + parser.error(f'could not prepare the source editor: {built.stderr[-500:]}') + if args.with_peer_server: + built = subprocess.run(['make', '-C', str(HERE), f'BUILD={args.build.resolve()}', + 'editor', 'document-server'], capture_output=True, text=True) + if built.returncode: + parser.error(f'could not prepare the server-only peer: {built.stderr[-500:]}') + if args.paged_storage or args.hybrid_storage: + verify_storage(args.build / 'pure-edit', 'hybrid' if args.hybrid_storage else 'paged') + helper = args.build.resolve() / 'measure-peak-rss' + built = subprocess.run(['make', '-C', str(HERE), f'BUILD={args.build.resolve()}', + str(helper)], capture_output=True, text=True) + if built.returncode: + parser.error(f'could not prepare the RSS helper: {built.stderr[-500:]}') + binaries = ['pure-edit-fixed' if args.fixed else 'pure-edit'] + if args.with_http: + binaries.append('pure-connections-fixed' if args.fixed else 'pure-connections') + if args.with_tui: + binaries.append('pure-edit-tui-fixed' if args.fixed else 'pure-edit-tui') + if args.with_peer: + binaries.append('pure-document-peer') + if args.with_peer_server: + binaries.append('pure-document-server') + if args.with_window: + binaries.append('pure-edit-window') + entries = {} + manifest_files = {} + for binary in binaries: + source = args.build / binary + content = without_debug(source, args.fixed) + if content[:4] != b'\x7fELF': + parser.error(f'{source} is not an ELF binary') + name = 'bin/' + binary + entries[name] = (content, 0o755) + manifest_files[name] = {'sha256': hashlib.sha256(content).hexdigest(), + 'bytes': len(content), 'needed': dependencies(source)} + program = (HERE / 'examples/editor.pure').read_bytes() + if not args.fixed: + entries['share/editor.pure'] = (program, 0o644) + manifest_files['share/editor.pure'] = {'sha256': hashlib.sha256(program).hexdigest(), + 'bytes': len(program)} + entries['RUN.md'] = ((RUN_FIXED if args.fixed else RUN).encode(), 0o644) + manifest_files['RUN.md'] = {'sha256': hashlib.sha256(entries['RUN.md'][0]).hexdigest(), + 'bytes': len(entries['RUN.md'][0])} + if args.paged_storage or args.hybrid_storage: + entries['STORAGE.md'] = ((HYBRID_STORAGE if args.hybrid_storage else PAGED_STORAGE).encode(), 0o644) + manifest_files['STORAGE.md'] = {'sha256': hashlib.sha256(entries['STORAGE.md'][0]).hexdigest(), + 'bytes': len(entries['STORAGE.md'][0])} + budgets = [reviewed_budget(args.build, + 'fixed-budget.json' if args.fixed else + 'hybrid-budget.json' if args.hybrid_storage else + 'paged-budget.json' if args.paged_storage else 'profile-budget.json', + ['pure-edit-fixed'] + + (['pure-connections-fixed'] if args.with_http else []) if args.fixed else + ['pure-edit'] + (['pure-connections'] if args.with_http else []))] + if args.with_peer: + budgets.append(reviewed_budget(args.build, 'peer-budget.json')) + if args.with_peer_server: + budgets.append(reviewed_budget(args.build, 'peer-server-budget.json')) + if args.with_tui: + budgets.append(reviewed_budget(args.build, + 'tui-budget.json' if args.fixed else + 'hybrid-tui-budget.json' if args.hybrid_storage else + 'source-tui-budget.json')) + if args.with_window: + budgets.append(reviewed_budget(args.build, 'window-budget.json')) + manifest = {'format': 'pure-document-preview-1', 'platform': 'linux-aarch64', + 'selected_hosts': binaries, 'shared_libraries_bundled': False, + 'reviewed_budgets': budgets, 'reviewed_memory': reviewed_memory(args.build, args.fixed), + 'files': manifest_files} + if args.fixed: + manifest['program_mode'] = 'build-time-prepared' + manifest['source_sha256'] = hashlib.sha256(program).hexdigest() + if args.with_tui: + manifest['reviewed_tui_memory'] = reviewed_tui_memory(args.build, not args.fixed) + if args.with_window: + manifest['features'] = ['native-document-key-switch'] + if args.paged_storage: + manifest['storage_profile'] = 'PUREP001-16k-page-research' + if args.hybrid_storage: + manifest['storage_profile'] = 'PUREB001+PUREP001-hybrid-16k-research' + maximum = None + package_profile = None + if args.fixed: + budget_path = HERE / 'fixed-package-budget.json' + package_budget = json.loads(budget_path.read_text()) + package_profile = 'cli' + ('_http' if args.with_http else '') + ('_tui' if args.with_tui else '') + maximum = package_budget['max_archive_bytes'][package_profile] + manifest['reviewed_package_budget'] = { + 'profile': package_budget['profile'], 'selection': package_profile, + 'max_archive_bytes': maximum, + 'budget_sha256': hashlib.sha256(budget_path.read_bytes()).hexdigest()} + elif (args.with_peer or args.with_peer_server) and not ( + args.with_http or args.with_tui or args.with_window or + args.paged_storage or args.hybrid_storage): + budget_path = HERE / 'peer-package-budget.json' + package_budget = json.loads(budget_path.read_text()) + package_profile = 'server_only' if args.with_peer_server else 'combined' + maximum = package_budget['max_archive_bytes'][package_profile] + manifest['reviewed_package_budget'] = { + 'profile': package_budget['profile'], 'selection': package_profile, + 'max_archive_bytes': maximum, + 'budget_sha256': hashlib.sha256(budget_path.read_bytes()).hexdigest()} + entries['manifest.json'] = ((json.dumps(manifest, indent=2, sort_keys=True) + '\n').encode(), 0o644) + stream = io.BytesIO() + with gzip.GzipFile(filename='', mode='wb', fileobj=stream, mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode='w') as archive: + for name in sorted(entries): + content, mode = entries[name] + add_bytes(archive, name, content, mode) + packed = stream.getvalue() + if maximum is not None and len(packed) > maximum: + parser.error(f'{package_profile} archive grew to {len(packed)} bytes ' + f'(reviewed maximum {maximum}); no package was published') + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('xb') as output: + output.write(packed) + print(json.dumps({'archive': str(args.output.resolve()), + 'sha256': hashlib.sha256(packed).hexdigest(), + 'bytes': len(packed), + 'selected_hosts': binaries}, sort_keys=True)) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/package_source.py b/prototypes/connections/package_source.py new file mode 100644 index 0000000..4a2487c --- /dev/null +++ b/prototypes/connections/package_source.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Make a deterministic, buildable source snapshot without result/build trees.""" +import argparse +import gzip +import hashlib +import io +import json +from pathlib import Path +import tarfile + +ROOT = Path(__file__).resolve().parents[2] +PREFIX = 'pure-source-preview' +SOURCE_ROOTS = ('prototypes/connections', 'prototypes/workbench', 'prototypes/common') +SKIP_DIRS = {'build', 'results', '__pycache__', 'node_modules', '.git', '.playwright-cli'} +SUFFIXES = {'.c', '.h', '.py', '.mjs', '.js', '.zig', '.json', '.pure', + '.html', '.css', '.svg', '.wasm', '.md', '.txt'} + + +def files(): + selected = {} + for relative_root in SOURCE_ROOTS: + for path in (ROOT / relative_root).rglob('*'): + relative = path.relative_to(ROOT) + if any(part in SKIP_DIRS for part in relative.parts) or path.is_symlink() or not path.is_file(): + continue + if path.name != 'Makefile' and path.suffix not in SUFFIXES: + continue + selected[relative.as_posix()] = path.read_bytes() + return selected + + +def add(archive, name, content): + info = tarfile.TarInfo(PREFIX + '/' + name) + info.size = len(content) + info.mode = 0o644 + info.mtime = info.uid = info.gid = 0 + info.uname = info.gname = '' + archive.addfile(info, io.BytesIO(content)) + + +def make_archive(output): + selected = files() + selected['RUN.md'] = selected.pop('prototypes/connections/SOURCE_PREVIEW.md') + manifest = {'format': 'pure-source-preview-1', 'files': { + name: {'bytes': len(content), 'sha256': hashlib.sha256(content).hexdigest()} + for name, content in sorted(selected.items())}} + selected['manifest.json'] = (json.dumps(manifest, indent=2, sort_keys=True) + '\n').encode() + output.parent.mkdir(parents=True, exist_ok=True) + with output.open('xb') as stream: + with gzip.GzipFile(fileobj=stream, mode='wb', filename='', mtime=0, compresslevel=9) as compressed: + with tarfile.open(fileobj=compressed, mode='w', format=tarfile.USTAR_FORMAT) as archive: + for name, content in sorted(selected.items()): + add(archive, name, content) + return {'archive': str(output.resolve()), 'bytes': output.stat().st_size, + 'sha256': hashlib.sha256(output.read_bytes()).hexdigest(), + 'source_files': len(manifest['files'])} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + print(json.dumps(make_archive(args.output), sort_keys=True)) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/paged-budget.json b/prototypes/connections/paged-budget.json new file mode 100644 index 0000000..1d578cd --- /dev/null +++ b/prototypes/connections/paged-budget.json @@ -0,0 +1,18 @@ +{ + "profile": "linux-aarch64-gcc14-selected-paged-document-storage", + "binaries": { + "pure-edit": { + "max_text_bytes": 40960, + "max_data_bytes": 1200, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + }, + "pure-connections": { + "max_text_bytes": 49152, + "max_data_bytes": 1300, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Selected 16 KiB-page document storage backend on Linux AArch64/GCC 14. Same linked-code and direct-dependency ceilings as the flat backend; excludes libc, process RAM, filesystem cache and journal, and optional TLS/GTK. Any failure requires review, not automatic relaxation." +} diff --git a/prototypes/connections/peer-budget.json b/prototypes/connections/peer-budget.json new file mode 100644 index 0000000..b0f7739 --- /dev/null +++ b/prototypes/connections/peer-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-selected-document-peer", + "binaries": { + "pure-document-peer": { + "max_text_bytes": 53248, + "max_data_bytes": 1600, + "max_bss_bytes": 64, + "needed": ["libssl.so.3", "libcrypto.so.3", "libc.so.6"] + } + }, + "scope": "Review threshold for the explicitly selected authenticated document host on Linux AArch64/GCC 14. Includes linked application code but excludes the shared OpenSSL/libc implementations, process heap, stack, mappings and kernel buffers. Check measured whole-process RSS separately." +} diff --git a/prototypes/connections/peer-package-budget.json b/prototypes/connections/peer-package-budget.json new file mode 100644 index 0000000..3403302 --- /dev/null +++ b/prototypes/connections/peer-package-budget.json @@ -0,0 +1,8 @@ +{ + "profile": "linux-aarch64-gcc14-cli-with-selected-document-peer", + "max_archive_bytes": { + "server_only": 51200, + "combined": 53248 + }, + "scope": "Compressed CLI plus one TLS peer, source definition and manifest on Linux AArch64. These are reviewed archive ceilings, not process memory or bundled OpenSSL size. Additional HTTP, TUI, GUI or research storage selections have separate costs." +} diff --git a/prototypes/connections/peer-server-budget.json b/prototypes/connections/peer-server-budget.json new file mode 100644 index 0000000..cbb3cc9 --- /dev/null +++ b/prototypes/connections/peer-server-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-selected-document-server", + "binaries": { + "pure-document-server": { + "max_text_bytes": 45056, + "max_data_bytes": 1408, + "max_bss_bytes": 64, + "needed": ["libssl.so.3", "libcrypto.so.3", "libc.so.6"] + } + }, + "scope": "Reviewed server-only TLS peer ceiling on Linux AArch64/GCC 14. Excludes linked native client code and shared OpenSSL/libc implementations; process RSS is measured separately." +} diff --git a/prototypes/connections/profile-budget.json b/prototypes/connections/profile-budget.json new file mode 100644 index 0000000..602f837 --- /dev/null +++ b/prototypes/connections/profile-budget.json @@ -0,0 +1,18 @@ +{ + "profile": "linux-aarch64-gcc14-default-document-hosts", + "binaries": { + "pure-edit": { + "max_text_bytes": 40960, + "max_data_bytes": 1200, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + }, + "pure-connections": { + "max_text_bytes": 49152, + "max_data_bytes": 1300, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation." +} diff --git a/prototypes/connections/program.c b/prototypes/connections/program.c new file mode 100644 index 0000000..780d11a --- /dev/null +++ b/prototypes/connections/program.c @@ -0,0 +1,374 @@ +#include "program.h" +#include "store.h" +#include +#include +#include + +enum { NAME = 256, NUMBER, SYMBOLS = 32, LAYOUTS = 4, FIELDS = 16 }; +typedef enum { BYTE_VALUE, RESULT_VALUE, LAYOUT_VALUE, INDEX_VALUE } ValueKind; +typedef struct { + char name[32]; + ValueKind kind; + PureRegion region; + size_t step, layout; + PureIndex index; +} Symbol; +typedef struct { + char name[32]; + size_t offset, length; + bool number, tail; +} Field; +typedef struct { + char name[32]; + Field fields[FIELDS]; + size_t count, prefix; + bool tail; +} Layout; +typedef struct { + const char *source; + size_t length, at, line, token_line; + int token; + char name[32]; + size_t number; + char *error; + size_t error_size; + bool failed, put, effect; + Symbol symbols[SYMBOLS]; + size_t count, layout_count; + Layout layouts[LAYOUTS]; + PureProgram program; +} Parser; + +static bool reject(Parser *p, const char *message) { + if (!p->failed && p->error_size) snprintf(p->error, p->error_size, "line %zu: %s", p->token_line, message); + p->failed = true; + return false; +} + +static bool letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static bool next(Parser *p) { + while (p->at < p->length) { + char c = p->source[p->at]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (c == '\n') ++p->line; + ++p->at; + } else if (c == '/' && p->at + 1 < p->length && p->source[p->at + 1] == '/') { + while (p->at < p->length && p->source[p->at] != '\n') ++p->at; + } else break; + } + p->token_line = p->line; + p->name[0] = 0; + p->token = 0; + if (p->at == p->length) return true; + char c = p->source[p->at++]; + if (letter(c)) { + size_t start = p->at - 1; + while (p->at < p->length && (letter(p->source[p->at]) || + (p->source[p->at] >= '0' && p->source[p->at] <= '9'))) ++p->at; + size_t size = p->at - start; + if (size >= sizeof(p->name)) return reject(p, "name exceeds 31 bytes"); + memcpy(p->name, p->source + start, size); + p->name[size] = 0; + p->token = NAME; + } else if (c >= '0' && c <= '9') { + size_t value = (size_t)(c - '0'); + while (p->at < p->length && p->source[p->at] >= '0' && p->source[p->at] <= '9') { + unsigned digit = (unsigned)(p->source[p->at++] - '0'); + if (value > (SIZE_MAX - digit) / 10) return reject(p, "integer overflow"); + value = value * 10 + digit; + } + p->number = value; + p->token = NUMBER; + } else if (c && strchr("{}().,=;:[]", c)) p->token = (unsigned char)c; + else return reject(p, "unexpected character"); + return true; +} + +static bool is_word(Parser *p, const char *word) { + return p->token == NAME && !strcmp(p->name, word); +} + +static bool word(Parser *p, const char *expected) { + return is_word(p, expected) ? next(p) : reject(p, expected); +} + +static bool token(Parser *p, int expected) { + return p->token == expected ? next(p) : reject(p, "unexpected token"); +} + +static Symbol *find(Parser *p, const char *name) { + for (size_t i = 0; i < p->count; ++i) + if (!strcmp(name, p->symbols[i].name)) return &p->symbols[i]; + return NULL; +} + +static bool field(Parser *p, const char *object, const char *name) { + return word(p, object) && token(p, '.') && word(p, name); +} + +static bool require_bytes(Parser *p, const Symbol *value) { + return value->kind == BYTE_VALUE || reject(p, "expected bytes; select a byte field"); +} + +static void require_length(Parser *p, size_t required) { + size_t *minimum = p->put ? &p->program.input_required : &p->program.read_required; + if (required > *minimum) *minimum = required; +} + +static Layout *find_layout(Parser *p, const char *name) { + for (size_t i = 0; i < p->layout_count; ++i) + if (!strcmp(name, p->layouts[i].name)) return &p->layouts[i]; + return NULL; +} + +static bool layout_definition(Parser *p) { + if (p->layout_count == LAYOUTS) return reject(p, "program exceeds 4 layouts"); + if (!next(p)) return false; + if (p->token != NAME || find_layout(p, p->name)) return reject(p, "expected a unique layout name"); + Layout *layout = &p->layouts[p->layout_count]; + strcpy(layout->name, p->name); + if (!next(p) || !token(p, '{')) return false; + while (p->token != '}') { + if (layout->count == FIELDS) return reject(p, "layout exceeds 16 fields"); + if (layout->tail) return reject(p, "variable bytes must be the last field"); + if (p->token != NAME) return reject(p, "expected a field name"); + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(layout->fields[i].name, p->name)) return reject(p, "duplicate field"); + Field *f = &layout->fields[layout->count]; + strcpy(f->name, p->name); + f->offset = layout->prefix; + if (!next(p) || !token(p, ':')) return false; + if (is_word(p, "u32le")) { + f->number = true; + f->length = 4; + if (!next(p)) return false; + } else if (is_word(p, "bytes")) { + if (!next(p)) return false; + if (p->token == '[') { + if (!next(p)) return false; + if (p->token != NUMBER) return reject(p, "fixed bytes requires a constant size"); + f->length = p->number; + if (!next(p) || !token(p, ']')) return false; + } else { f->tail = true; layout->tail = true; } + } else return reject(p, "supported field types: u32le, bytes[N], bytes"); + if (f->length > PURE_OBJECT_LIMIT - layout->prefix) return reject(p, "layout exceeds object bound"); + layout->prefix += f->length; + ++layout->count; + if (!token(p, ';')) return false; + } + if (!layout->count) return reject(p, "empty layout"); + ++p->layout_count; + return token(p, '}'); +} + +static bool reference(Parser *p, Symbol *out) { + if (is_word(p, "request")) { + if (!p->put) return reject(p, "request.body is only available in on put"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return field(p, "request", "body"); + } + Symbol *symbol = p->token == NAME ? find(p, p->name) : NULL; + if (!symbol) return reject(p, "unknown value"); + *out = *symbol; + if (!next(p)) return false; + if (p->token != '.') return true; + if (!next(p)) return false; + if (symbol->kind == RESULT_VALUE) { + if (p->put) return reject(p, "write result has no body; use the input value"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return word(p, "body"); + } + if (symbol->kind != LAYOUT_VALUE || p->token != NAME) return reject(p, "expected a layout field"); + Layout *layout = &p->layouts[symbol->layout]; + Field *f = NULL; + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(p->name, layout->fields[i].name)) f = &layout->fields[i]; + if (!f) return reject(p, "unknown layout field"); + size_t offset = symbol->region.offset + f->offset; + *out = (Symbol){.kind = f->number ? INDEX_VALUE : BYTE_VALUE}; + if (f->number) out->index = (PureIndex){.value = offset, .from_u32le = true}; + else out->region = (PureRegion){.offset = offset, + .length = f->tail ? (symbol->region.to_end ? 0 : symbol->region.length - f->offset) : f->length, + .to_end = f->tail && symbol->region.to_end}; + return next(p); +} + +static bool byte_reference(Parser *p, Symbol *out) { + return reference(p, out) && require_bytes(p, out); +} + +static bool index_value(Parser *p, PureIndex *out) { + if (p->token == NUMBER) { + *out = (PureIndex){.value = p->number}; + return next(p); + } + Symbol value; + if (!reference(p, &value)) return false; + if (value.kind != INDEX_VALUE) return reject(p, "range requires an integer or u32le field"); + *out = value.index; + return true; +} + +static bool layout_view(Parser *p, Symbol *out) { + if (!next(p) || !token(p, '(')) return false; + Layout *layout = p->token == NAME ? find_layout(p, p->name) : NULL; + if (!layout) return reject(p, "unknown layout"); + if (!next(p) || !token(p, ',')) return false; + Symbol source; + if (!byte_reference(p, &source) || !token(p, ')')) return false; + if (source.step) return reject(p, "layout view requires a statically located source"); + size_t bound = source.region.to_end ? PURE_OBJECT_LIMIT - source.region.offset : source.region.length; + if (layout->prefix > bound) return reject(p, "layout exceeds source bound"); + require_length(p, source.region.offset + layout->prefix); + *out = (Symbol){.kind = LAYOUT_VALUE, .region = source.region, .layout = (size_t)(layout - p->layouts)}; + if (!layout->tail) { out->region.length = layout->prefix; out->region.to_end = false; } + return true; +} + +static bool view(Parser *p, Symbol *out) { + if (is_word(p, "view")) return layout_view(p, out); + if (!is_word(p, "range")) return reference(p, out); + Symbol parent; + PureIndex offset, length; + if (!next(p) || !token(p, '(') || !byte_reference(p, &parent) || !token(p, ',') || + !index_value(p, &offset) || !token(p, ',') || !index_value(p, &length) || !token(p, ')')) return false; + bool dynamic = parent.step || offset.from_u32le || length.from_u32le; + *out = (Symbol){.kind = BYTE_VALUE}; + if (dynamic) { + if (!p->put) return reject(p, "data-dependent ranges currently require PUT input"); + if (p->program.input_steps == PURE_DYNAMIC_LIMIT) return reject(p, "input exceeds 8 dynamic ranges"); + if ((!offset.from_u32le && offset.value > PURE_OBJECT_LIMIT) || + (!length.from_u32le && length.value > PURE_OBJECT_LIMIT)) return reject(p, "range exceeds object bound"); + PureRangeStep *step = &p->program.steps[p->program.input_steps++]; + *step = (PureRangeStep){.source = parent.region, .source_step = parent.step, + .offset = offset, .length = length}; + out->step = p->program.input_steps; + } else { + size_t bound = parent.region.to_end ? PURE_OBJECT_LIMIT - parent.region.offset : parent.region.length; + if (offset.value > bound || length.value > bound - offset.value) return reject(p, "range exceeds its source bound"); + out->region = (PureRegion){.offset = parent.region.offset + offset.value, .length = length.value}; + require_length(p, out->region.offset + out->region.length); + } + ++p->program.range_definitions; + return true; +} + +static bool effect(Parser *p, Symbol *out) { + if (p->effect) return reject(p, "only one storage effect is supported per handler"); + if (!word(p, "store") || !token(p, '.')) return false; + bool splice = p->put && is_word(p, "splice"); + if (!word(p, p->put ? (splice ? "splice" : "write") : "read") || + !token(p, '(') || !field(p, "request", "key")) return false; + if (p->put) { + Symbol value; + if (!token(p, ',') || !field(p, "request", "revision") || !token(p, ',')) return false; + if (splice) { + if (!index_value(p, &p->program.splice_offset) || !token(p, ',') || + !index_value(p, &p->program.splice_remove) || !token(p, ',')) return false; + if ((!p->program.splice_offset.from_u32le && p->program.splice_offset.value > PURE_OBJECT_LIMIT) || + (!p->program.splice_remove.from_u32le && p->program.splice_remove.value > PURE_OBJECT_LIMIT)) + return reject(p, "splice exceeds object bound"); + p->program.splice = true; + } + if (!byte_reference(p, &value)) return false; + p->program.write = value.region; + p->program.write_step = value.step; + if (splice && p->token == ',') { + if (!next(p)) return false; + if (p->token != NUMBER || p->number > PURE_OBJECT_LIMIT) + return reject(p, "splice limit requires a constant within the object bound"); + p->program.splice_limit = (uint32_t)p->number; + if (!next(p)) return false; + } + } + if (!token(p, ')')) return false; + out->kind = RESULT_VALUE; + p->effect = true; + return true; +} + +static bool handler(Parser *p) { + p->count = 0; + p->effect = false; + if (!token(p, '{')) return false; + while (is_word(p, "let")) { + if (p->put && p->effect) return reject(p, "all views must precede the storage effect; reply must follow it"); + if (p->count == SYMBOLS) return reject(p, "handler exceeds 32 definitions"); + if (!next(p)) return false; + if (p->token != NAME || is_word(p, "request") || is_word(p, "store") || + is_word(p, "range") || is_word(p, "view")) return reject(p, "expected a non-reserved definition name"); + if (find(p, p->name)) return reject(p, "duplicate definition"); + char name[32]; + strcpy(name, p->name); + Symbol symbol = {0}; + if (!next(p) || !token(p, '=')) return false; + if (is_word(p, "store")) { + if (!effect(p, &symbol)) return false; + } else { + if (!view(p, &symbol)) return false; + ++p->program.view_definitions; + } + if (!token(p, ';')) return false; + strcpy(symbol.name, name); + p->symbols[p->count++] = symbol; + } + if (!p->effect) return reject(p, "handler requires its storage effect"); + if (!word(p, "reply") || !token(p, '(')) return false; + Symbol *result = p->token == NAME ? find(p, p->name) : NULL; + if (!result || result->kind != RESULT_VALUE) return reject(p, "reply requires this handler's storage result"); + if (!next(p)) return false; + Symbol body = {0}; + bool has_body = p->token == ','; + if (has_body && (!next(p) || !byte_reference(p, &body))) return false; + if (!token(p, ')') || !token(p, ';') || !token(p, '}')) return false; + if (p->put) { + p->program.reply = body.region; + p->program.reply_step = body.step; + p->program.reply_input = has_body; + } else p->program.read = body.region; + return true; +} + +bool pure_program_parse(const char *source, size_t length, PureProgram *out, + char *error, size_t error_size) { + Parser p = {.source = source, .length = length, .line = 1, .token_line = 1, + .error = error, .error_size = error_size, + .program.splice_limit = PURE_OBJECT_LIMIT}; + if (error_size) error[0] = 0; + if (length > PURE_PROGRAM_LIMIT) return reject(&p, "program exceeds 16 KiB"); + if (!next(&p)) return false; + while (is_word(&p, "layout")) if (!layout_definition(&p)) return false; + unsigned handlers = 0; + while (p.token) { + if (!word(&p, "on")) return false; + if (!is_word(&p, "get") && !is_word(&p, "put")) return reject(&p, "expected get or put handler"); + p.put = is_word(&p, "put"); + unsigned bit = p.put ? 2 : 1; + if (handlers & bit) return reject(&p, "duplicate handler"); + handlers |= bit; + if (!next(&p) || !handler(&p)) return false; + } + if (handlers != 3) return reject(&p, "both get and put handlers are required"); + *out = p.program; /* Invalid preparation never changes the installed plan. */ + return true; +} + +bool pure_program_load(const char *path, PureProgram *out, char *error, size_t error_size) { + FILE *file = fopen(path, "rb"); + if (!file) { + if (error_size) snprintf(error, error_size, "cannot open program"); + return false; + } + char source[PURE_PROGRAM_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool bad = ferror(file) != 0; + if (fclose(file)) bad = true; + if (bad) { + if (error_size) snprintf(error, error_size, "cannot read program"); + return false; + } + return pure_program_parse(source, length, out, error, error_size); +} diff --git a/prototypes/connections/program.h b/prototypes/connections/program.h new file mode 100644 index 0000000..078a382 --- /dev/null +++ b/prototypes/connections/program.h @@ -0,0 +1,50 @@ +#ifndef PURE_PROGRAM_H +#define PURE_PROGRAM_H +#include "flow.h" +#include + +#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; + uint32_t splice_limit; /* Final stored bytes; checked under the writer lock. */ + PureIndex splice_offset, splice_remove; +} PureProgram; + +#if SIZE_MAX == UINT64_MAX +_Static_assert(sizeof(PureProgram) == 680, "prepared document definition grew"); +#endif + +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/connections/result.h b/prototypes/connections/result.h new file mode 100644 index 0000000..8abb0f2 --- /dev/null +++ b/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/connections/results/2026-09-23-byte-storage/README.md b/prototypes/connections/results/2026-09-23-byte-storage/README.md new file mode 100644 index 0000000..f28229b --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/README.md @@ -0,0 +1,31 @@ +# 019 검증 자료 + +> 작성 기능 도입 후 검증은 [020](../2026-09-23-prepared-connections/README.md)에 있다. +> 이 디렉터리의 소스·결과는 도입 전 기록으로 보존한다. + +SQL 의존성을 제거한 C17/POSIX 저장 경로와 실제 루프백 TCP·기존 Workbench CLI의 +기능·수명 검사다. 시간·처리량·전체 RSS 비교는 수행하지 않았다. + +- [native.json](native.json): 일반 빌드의 16개 통합 시나리오, 종료 시 자원 수, 환경·소스·실행 파일 해시. +- [sanitized.json](sanitized.json): ASan·UBSan 빌드의 같은 시나리오. 누수 검사를 끄지 않았다. +- [verification.json](verification.json): 명령, 단위 검사 결과, 링크 의존성, 소스 사본·실행 파일 일치 확인. +- `sources/`: 결과에 기록된 소스·Wasm 사본. 이후 작업 트리가 바뀌면 이 파일들을 별도 작업 트리에 사용한다. + +단위 검사는 필요한 범위만 읽기, 갱신 중 이전 참조, 공개 전후 오류, 공개 전 자식 프로세스 +종료 등을 포함한다. 처음 검사한 범위 fixture는 4 KiB를 가정했으나 현재 환경은 16 KiB +페이지였다. 최종 검사는 실제 OS 페이지 크기를 사용해 페이지 경계를 넘고, 서로 다른 +페이지의 잘못된 참조도 구분할 수 있는 데이터로 수정했다. 초기 실행 결과는 `initial/`에 +보존했다. 그때와 최종 소스의 유일한 차이는 `initial/test-flow.c`이며, 기록의 해시와 맞는다. +성능 비교 자료로 사용하지 않는다. + +전원 장애나 실제 디스크 고장을 재현한 것은 아니다. fsync 전후의 실패 주입과 프로세스 +종료를 검사했다. 본문 예산은 논리적인 보유 바이트 수이며 매핑·페이지 캐시·디스크·소켓· +클라이언트 메모리를 합친 상한이 아니다. 전송 시점에 따라 수신 접두부 복사량과 쓰기 경쟁의 +승자가 바뀔 수 있으므로 빌드 간 진단 수치의 차이를 최적화 결과로 해석하지 않는다. + +재현은 [구현 README](../../README.md)의 `make check` 명령을 따른다. +`EVIDENCE=/새/경로.json`을 추가하면 새 파일로 기록하며 기존 자료는 덮어쓰지 않는다. +이 환경에서는 소켓·자식 프로세스·LeakSanitizer의 샌드박스 제한 때문에 승인된 환경에서 +실행했다. 외부 서비스는 사용하지 않았다. macOS 실행은 아직 확인하지 않았다. + +이전 SQLite 경로의 자료는 [018](../2026-09-23-native-connections/README.md)에 별도로 있다. diff --git a/prototypes/connections/results/2026-09-23-byte-storage/initial/native.json b/prototypes/connections/results/2026-09-23-byte-storage/initial/native.json new file mode 100644 index 0000000..450a8ac --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/initial/native.json @@ -0,0 +1,116 @@ +{ + "kind": "native-byte-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 11, + "gets": 14, + "conflicts": 5, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115417, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39176 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "2f8c664c61f0ea0a26574530c165a7fdff1058902b9938e73d146927b1fd44eb", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "9b88dfd664bf85e3fd1e971bf5d1b5bd408f6c2a069ffd02d41c05973d8cd2dd", + "prototypes/connections/store.h": "8675d39a02558b8f877477b3715e15816d12f4b4864eda19a63635941503c774", + "prototypes/connections/server.c": "e85b6d7f91e14eed6b22a4615654c33e9cc3163020ffa80fbd201f8f055eb3c2", + "prototypes/connections/check.py": "30363c718294540fbc653223fdf2a07dbcafbfa8bf1470aa5704a33ce0ea2b01", + "prototypes/connections/test-flow.c": "e209f1d76e076d5f1a680908bbfbfb0b07efc79ec99b308e544fe3902ef26d05", + "prototypes/connections/Makefile": "82995e6c3eda516c2fae02d680dff90371fb595edc2c5c9d4b3e5fd05137d36b", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/initial/sanitized.json b/prototypes/connections/results/2026-09-23-byte-storage/initial/sanitized.json new file mode 100644 index 0000000..b1d57b3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/initial/sanitized.json @@ -0,0 +1,116 @@ +{ + "kind": "native-byte-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39176 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "bd016cc93b1bd2a9c01d7f84bcc212e049b28e41528d52a90bdb70fb13100cb1", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "9b88dfd664bf85e3fd1e971bf5d1b5bd408f6c2a069ffd02d41c05973d8cd2dd", + "prototypes/connections/store.h": "8675d39a02558b8f877477b3715e15816d12f4b4864eda19a63635941503c774", + "prototypes/connections/server.c": "e85b6d7f91e14eed6b22a4615654c33e9cc3163020ffa80fbd201f8f055eb3c2", + "prototypes/connections/check.py": "30363c718294540fbc653223fdf2a07dbcafbfa8bf1470aa5704a33ce0ea2b01", + "prototypes/connections/test-flow.c": "e209f1d76e076d5f1a680908bbfbfb0b07efc79ec99b308e544fe3902ef26d05", + "prototypes/connections/Makefile": "82995e6c3eda516c2fae02d680dff90371fb595edc2c5c9d4b3e5fd05137d36b", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/initial/test-flow.c b/prototypes/connections/results/2026-09-23-byte-storage/initial/test-flow.c new file mode 100644 index 0000000..23798b4 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/initial/test-flow.c @@ -0,0 +1,178 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + unsigned char bytes[12288]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)i; + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, sizeof(bytes)}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", 4079, 4201, 4201, &first) == PURE_OK); + CHECK(first.body.size == 4201 && !memcmp(first.body.data, bytes + 4079, 4201)); + pure_store_release(&first); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/native.json b/prototypes/connections/results/2026-09-23-byte-storage/native.json new file mode 100644 index 0000000..52bea4c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/native.json @@ -0,0 +1,116 @@ +{ + "kind": "native-byte-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39176 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "2f8c664c61f0ea0a26574530c165a7fdff1058902b9938e73d146927b1fd44eb", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "9b88dfd664bf85e3fd1e971bf5d1b5bd408f6c2a069ffd02d41c05973d8cd2dd", + "prototypes/connections/store.h": "8675d39a02558b8f877477b3715e15816d12f4b4864eda19a63635941503c774", + "prototypes/connections/server.c": "e85b6d7f91e14eed6b22a4615654c33e9cc3163020ffa80fbd201f8f055eb3c2", + "prototypes/connections/check.py": "30363c718294540fbc653223fdf2a07dbcafbfa8bf1470aa5704a33ce0ea2b01", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/Makefile": "82995e6c3eda516c2fae02d680dff90371fb595edc2c5c9d4b3e5fd05137d36b", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sanitized.json b/prototypes/connections/results/2026-09-23-byte-storage/sanitized.json new file mode 100644 index 0000000..e2db165 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sanitized.json @@ -0,0 +1,116 @@ +{ + "kind": "native-byte-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39176 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39176 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "bd016cc93b1bd2a9c01d7f84bcc212e049b28e41528d52a90bdb70fb13100cb1", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "9b88dfd664bf85e3fd1e971bf5d1b5bd408f6c2a069ffd02d41c05973d8cd2dd", + "prototypes/connections/store.h": "8675d39a02558b8f877477b3715e15816d12f4b4864eda19a63635941503c774", + "prototypes/connections/server.c": "e85b6d7f91e14eed6b22a4615654c33e9cc3163020ffa80fbd201f8f055eb3c2", + "prototypes/connections/check.py": "30363c718294540fbc653223fdf2a07dbcafbfa8bf1470aa5704a33ce0ea2b01", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/Makefile": "82995e6c3eda516c2fae02d680dff90371fb595edc2c5c9d4b3e5fd05137d36b", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..5f5005b --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/Makefile @@ -0,0 +1,20 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build + +.PHONY: all check +all: $(BUILD)/pure-connections + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/test-flow $(BUILD)/pure-connections + $(BUILD)/test-flow + python3 check.py --server $(BUILD)/pure-connections $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/check.py new file mode 100644 index 0000000..1d7b403 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/check.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""Exercise local TCP, immutable byte extents, and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import fcntl +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import struct +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, storage, budget=4 * 1024 * 1024, timeout=5000): + self.process = subprocess.Popen([str(binary), '--store', str(storage), '--port', '0', + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory): + storage = directory / 'objects' + checks, records = [], [] + with serving(binary, storage) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + with (storage / '.writer.lock').open('r+b') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + fcntl.lockf(lock, fcntl.LOCK_UN) + broken = storage / 'obj-broken' + broken.write_bytes(b'incomplete') + assert rpc(server, 'GET', 'broken')[0] == 500 + assert rpc(server, 'PUT', 'broken', b'failure', 0)[0] == 500 + assert broken.read_bytes() == b'incomplete' + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('writer contention and invalid extent fail without overwriting data') + + with serving(binary, storage) as second_writer: + with ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(rpc, target, 'PUT', 'race', value, 0) + for target, value in [(server, b'first'), (second_writer, b'second')]] + statuses = [task.result()[0] for task in tasks] + assert statuses.count(200) == 1 and all(status in [200, 412, 503] for status in statuses) + status, headers, body = rpc(server, 'GET', 'race') + assert status == 200 and headers['ETag'] == '"1"' and body in [b'first', b'second'] + records.append(second_writer.stats) + checks.append('two native writers cannot both create the same expected revision') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the immutable file whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock storage. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + wide = storage / 'obj-wide_revision' + raw = wide.read_bytes() + wide.write_bytes(raw[:8] + struct.pack('= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + return {'kind': 'native-byte-path-correctness-not-performance', 'checks': checks, + 'server_runs': records, 'storage': server.ready['storage']} + + +def rpc_bytes(path): + # CLI export is pretty JSON; wire export is compact JSON with the same model. + return json.dumps(json.loads(path.read_text()), ensure_ascii=False, separators=(',', ':')).encode() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--server', type=Path, default=ROOT / 'build/pure-connections') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + binary = args.server.resolve() + with tempfile.TemporaryDirectory(prefix='pure-connections-check-') as temporary: + result = check(binary, Path(temporary)) + result['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + result['environment'] = {'platform': platform.platform(), 'machine': platform.machine(), + 'python': platform.python_version(), 'node': subprocess.check_output(['node', '--version'], text=True).strip()} + result['source_sha256'] = {str(path.relative_to(ROOT.parent.parent)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [*(ROOT / name for name in ('flow.c', 'flow.h', 'store.c', 'store.h', 'server.c', 'check.py', 'test-flow.c', 'Makefile')), + *(WORKBENCH / name for name in ('remote.mjs', 'cli.mjs', 'web/core.wasm', 'web/runtime.mjs', + 'web/compiler.mjs', 'web/project.mjs', 'examples/direct.pure'))]} + output = json.dumps(result, ensure_ascii=False, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/server.c new file mode 100644 index 0000000..cea5af8 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/server.c @@ -0,0 +1,347 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received, charged; + unsigned char *body; + bool put; + int64_t expected; + PureStoredView stored; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + size_t budget, retained, peak_retained, active, peak_active; + uint64_t accepted, commits, gets, conflicts, budget_rejections, expired; + uint64_t releases, prefix_copy_bytes, borrowed_output_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + bool owned = c->body || c->stored.mapped_size || c->charged; + pure_store_release(&c->stored); + free(c->body); + c->body = NULL; + assert(c->charged <= c->server->retained); + c->server->retained -= c->charged; + c->charged = 0; + if (owned) ++c->server->releases; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else if (c->body || c->stored.mapped_size || c->charged) release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static bool charge(Connection *c, size_t size) { + Server *s = c->server; + if (size > s->budget - s->retained) { ++s->budget_rejections; return false; } + s->retained += size; + c->charged += size; + if (s->retained > s->peak_retained) s->peak_retained = s->retained; + return true; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + if (c->body || c->stored.mapped_size || c->charged) release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void run_request(Connection *c) { + PureResult result; + int64_t revision = 0; + if (c->put) { + result = pure_store_put(c->server->path, c->key, c->expected, + (PureSpan){c->body, c->body_size}, &revision); + /* The synchronous write no longer accesses this input on any result. */ + release_payload(c); + if (result == PURE_OK) ++c->server->commits; + } else { + result = pure_store_get(c->server->path, c->key, + c->server->budget - c->server->retained, &c->stored); + if (result == PURE_OK) { + if (!charge(c, c->stored.body.size)) abort(); /* Same owner thread. */ + revision = c->stored.revision; + ++c->server->gets; + c->server->borrowed_output_bytes += c->stored.body.size; + } else if (result == PURE_BUDGET) ++c->server->budget_rejections; + } + switch (result) { + case PURE_OK: respond(c, 200, "OK", "none", revision, c->stored.body); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + ++c->server->conflicts; + respond(c, 412, "Precondition Failed", "conflict", revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "store_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + case PURE_UNCERTAIN: fail(c, 500, "Internal Server Error", "durability_unknown"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + if (!c->put) { run_request(c); return; } + if (!charge(c, c->body_size)) { fail(c, 503, "Service Unavailable", "payload_budget"); return; } + c->body = malloc(c->body_size ? c->body_size : 1); + if (!c->body) { fail(c, 503, "Service Unavailable", "allocation"); return; } + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + for (int i = 1; i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--store")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!server.path) goto usage; + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize byte store directory\n", stderr); return 1; } + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"storage\":\"immutable-byte-extents-v1\"}\n", ntohs(address.sin_port)); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.retained, server.peak_retained, server.budget, server.peak_active, + server.accepted, server.commits, server.gets, server.conflicts, server.budget_rejections, + server.expired, server.releases, server.prefix_copy_bytes, server.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.c new file mode 100644 index 0000000..d0111d0 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.c @@ -0,0 +1,225 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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}; +} + +static PureResult view_range(const char *path, const char *key, size_t offset, + size_t length, bool whole, 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 (whole) { offset = 0; length = size; } + 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 view_range(path, key, 0, 0, true, 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 view_range(path, key, offset, length, false, available, out); +} + +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, 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; + 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; + } + fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (fd >= 0) { + size_t size; + if (!read_header(fd, revision, &size)) goto finish; + close(fd); + fd = -1; + } else if (errno != ENOENT) goto finish; + if (*revision != expected) { result = PURE_CONFLICT; goto finish; } + if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + 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, body.size); + if (!write_bytes(fd, header, sizeof(header)) || fault(PURE_AFTER_HEADER) || + !write_bytes(fd, body.data, body.size) || 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 (*temporary) unlinkat(directory, temporary, 0); + if (lock >= 0) close(lock); + close(directory); + return result; +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.h new file mode 100644 index 0000000..d9bcc1c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/store.h @@ -0,0 +1,34 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include + +#define PURE_OBJECT_LIMIT ((2u * 6u + 32u) * 32768u + 8192u) +typedef enum { + PURE_OK, PURE_NOT_FOUND, PURE_CONFLICT, PURE_BUSY, PURE_BUDGET, + PURE_LIMIT, PURE_STORE_ERROR, PURE_UNCERTAIN +} PureResult; +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); +void pure_store_release(PureStoredView *); +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, 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/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..5ff028d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/connections/test-flow.c @@ -0,0 +1,183 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + long page = sysconf(_SC_PAGESIZE); + CHECK(page >= 128 && (size_t)page <= PURE_OBJECT_LIMIT / 3); + size_t size = (size_t)page * 3, offset = (size_t)page + 13, length = (size_t)page + 105; + unsigned char *bytes = malloc(size); + CHECK(bytes); + for (size_t i = 0; i < size; ++i) bytes[i] = (unsigned char)((i * 17u) ^ (i >> 8) ^ (i >> 16)); + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, size}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", offset, length, length, &first) == PURE_OK); + CHECK(first.body.size == length && !memcmp(first.body.data, bytes + offset, length)); + pure_store_release(&first); + free(bytes); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/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/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/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/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/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/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/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/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/core.wasm new file mode 100644 index 0000000..79c50a8 Binary files /dev/null and b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/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/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-byte-storage/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/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/connections/results/2026-09-23-byte-storage/verification.json b/prototypes/connections/results/2026-09-23-byte-storage/verification.json new file mode 100644 index 0000000..418c75a --- /dev/null +++ b/prototypes/connections/results/2026-09-23-byte-storage/verification.json @@ -0,0 +1,52 @@ +{ + "date": "2026-09-23", + "scope": "SQL-free storage correctness, lifetimes and admission bounds; not a performance comparison", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "os_page_size_bytes": 16384, + "cwd": "prototypes/connections", + "native_command": "make check EVIDENCE=results/2026-09-23-byte-storage/native.json", + "sanitized_command": "make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-byte-storage/sanitized.json", + "core_checks": { + "native": "passed", + "asan_ubsan": "passed", + "leak_detection_disabled": false, + "coverage": [ + "partial transfer and single release", + "range access including actual page boundaries and nonzero mapping offsets", + "immutable read across replacement", + "budget, conflicts, empty data, revision exhaustion", + "invalid file rejection", + "failure before publication preserves prior version", + "failure after publication reports uncertain durability", + "child exit before publication preserves prior version and releases writer lock" + ], + "native_binary_sha256": "a3189c0db63960bdf7b464d3813403065d7222366c00895e8eaad66450fd2f27", + "sanitized_binary_sha256": "acf03ba3881be9d534fc2419abed964f27d4dbd23173a34ab3efdeb831b7547f" + }, + "integration_scenarios_each": 16, + "source_hashes_equal": true, + "source_snapshots": "sources/", + "source_snapshots_match_records": true, + "final_binaries_match_records": true, + "native_dynamic_dependencies_ldd": [ + "\tlinux-vdso.so.1 (0x00007ffed5098000)", + "\tlibc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x00007ffed4e50000)", + "\t/lib/ld-linux-aarch64.so.1 (0x00007ffed5060000)" + ], + "initial_run": { + "directory": "initial/", + "difference": "Only test-flow.c changed: page-boundary fixture now uses actual OS page size and distinct per-page contents", + "original_test_sha256_verified": true + }, + "limitations": [ + "Linux AArch64 only; macOS not executed", + "No actual power-loss or disk-fault testing", + "Logical payload budget excludes page rounding, page cache, disk retention, OS sockets and client allocations", + "Unpublished files left by crashes are ignored but not automatically collected", + "Synchronous filesystem operations can delay other connections and timeout enforcement", + "Single-threaded control API with one cooperating process writer lock per directory", + "Whole-object replacement, no multi-object transaction or payload checksum", + "Stored files must not be mutated in place by external writers", + "Pure-authored external effects and automatic path optimization remain unimplemented" + ] +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/README.md b/prototypes/connections/results/2026-09-23-document-edits/README.md new file mode 100644 index 0000000..ee6ed76 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/README.md @@ -0,0 +1,27 @@ +# 문서 부분 변경과 micro 비용 경계 + +2026-09-23, Linux AArch64 / GCC 14.2.0. 정확성·구조 비용 검증이며 성능 순위 자료가 아니다. + +- `native.json`, `sanitized.json`: 각 36개 통합 시나리오, 실행 코어와 splice 단위 검사, 바이너리·소스 해시. +- `core.json`: 실행 전용 아카이브의 섹션·심볼·구조체 비용과 검사 상한. +- `boundary-checks.json`: 코어만 선택한 빌드의 파일, 비용 증가 시 검사 실패, 전체 CLI/서버 ELF 비용. +- `verification.json`: 두 빌드의 소스와 바이너리 확인, 서버 종료 시 회수 확인. +- `sources/`: 해시가 일치하는 소스·기존 클라이언트 37개. 검증에 사용한 Workbench Wasm도 포함한다. + +실행 명령 (`prototypes/connections` 기준): + +```sh +make all +make check EVIDENCE=results/2026-09-23-document-edits/native.json +make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-document-edits/sanitized.json +make check-core CORE_EVIDENCE=results/2026-09-23-document-edits/core.json +``` + +기존 결과를 덮어쓰지 않는다. 재현 시 출력 인자를 생략하거나 새 경로를 사용한다. +동결 소스의 `sources/prototypes/connections`에서도 같은 빌드·검사가 가능하다. +C17 컴파일러·make·Python·Node·로컬 TCP/자식 프로세스 실행이 필요하며, 크기 검사는 +GNU 호환 ar/nm/size를 사용한다. ASan 계측물에는 release 크기 상한을 적용하지 않는다. + +1 MiB 문서 편집의 11 B는 입력 크레딧이다. 파일을 재작성하는 4 KiB 호스트 스택 버퍼, +커널·파일·allocator·파서·서버 비용을 포함한 전체 메모리라고 해석하지 않는다. +[구현, 수치 범위와 한계](../../../../docs/research/023-document-edits.md). diff --git a/prototypes/connections/results/2026-09-23-document-edits/boundary-checks.json b/prototypes/connections/results/2026-09-23-document-edits/boundary-checks.json new file mode 100644 index 0000000..c24fa21 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/boundary-checks.json @@ -0,0 +1,34 @@ +{ + "core_only_build_files": [ + "evaluate.o", + "libpure-execution.a", + "runtime.o" + ], + "reject_smaller_budget": [ + "domain_bytes: 200 > 199" + ], + "reject_added_dependency_and_static_state": [ + "unexpected archive members", + "bss: 4096 > 0", + "unexpected dependencies: ['malloc']" + ], + "selected_binaries": { + "pure-edit": { + "debug_file_bytes": 177384, + "stripped_file_bytes": 67904, + "sections": " text\t data\t bss\t dec\t hex\tfilename\n 35807\t 944\t 16\t 36767\t 8f9f\t/home/eunhhu/work/pure/prototypes/connections/build/pure-edit\n", + "direct_dynamic_libraries": [ + "0x0000000000000001 (NEEDED) Shared library: [libc.so.6]" + ] + }, + "pure-connections": { + "debug_file_bytes": 199080, + "stripped_file_bytes": 68000, + "sections": " text\t data\t bss\t dec\t hex\tfilename\n 42761\t 1040\t 16\t 43817\t ab29\t/home/eunhhu/work/pure/prototypes/connections/build/pure-connections\n", + "direct_dynamic_libraries": [ + "0x0000000000000001 (NEEDED) Shared library: [libc.so.6]" + ] + } + }, + "scope": "Selected files and direct ELF dependencies, not complete dependency memory, RSS, stack or latency." +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/core.json b/prototypes/connections/results/2026-09-23-document-edits/core.json new file mode 100644 index 0000000..8c0caf7 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/core.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/connections/results/2026-09-23-document-edits/native.json b/prototypes/connections/results/2026-09-23-document-edits/native.json new file mode 100644 index 0000000..c9bb1d4 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/native.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "07c328c98769d02d544aea2fc5389048a7620c00d9b3ea49b9c7ba45e33984b1", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "0e4ec2c78334af2056af376a6ba9aef7416a34c6d4fbd9082f4abb640e6de28c", + "editor_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "binary_sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "5d08639b5a71572553bda47cbd35562805d601aaa81f20731cd9154768ec025e", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "e1735316769b4ce4d5904b9587ccfa3ebeafa26517c53dba4491191d58c97b8e", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "b5e7ed077be1333f3a948844608785dbab98bdc9c4ae6fe9c6a4805655fc9c63", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sanitized.json b/prototypes/connections/results/2026-09-23-document-edits/sanitized.json new file mode 100644 index 0000000..0e2dbe4 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sanitized.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "776be59b5e35de39587fd5a8f703160572b3a6c098a22af635e597208bba1950", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "ec0db33c9937034148d934c658c98aa98a2fdb279a92f96a2d7374e2d292468e", + "editor_sha256": "23ee47873d7924163ace86cad44946e71fd21a6122930f35eff1c36f5e418bdc", + "binary_sha256": "f145f0f30c944e400ef8d1594bd0387f42f42569290f28c7a1272a4ee8849d4c", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "5d08639b5a71572553bda47cbd35562805d601aaa81f20731cd9154768ec025e", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "e1735316769b4ce4d5904b9587ccfa3ebeafa26517c53dba4491191d58c97b8e", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "b5e7ed077be1333f3a948844608785dbab98bdc9c4ae6fe9c6a4805655fc9c63", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..d1b46e2 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/Makefile @@ -0,0 +1,59 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build + +.PHONY: all core editor server check check-core +CORE = runtime.c evaluate.c +HOST = store.c store-port.c +HEADERS = runtime.h program.h store.h store-port.h flow.h result.h +all: core editor server +core: $(BUILD)/libpure-execution.a +editor: $(BUILD)/pure-edit +server: $(BUILD)/pure-connections + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c $(HOST) flow.c program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c $(HOST) flow.c program.c $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/pure-edit: edit.c $(HOST) program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) edit.c $(HOST) program.c $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/runtime.o: runtime.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c runtime.c -o $@ + +$(BUILD)/evaluate.o: evaluate.c program.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c evaluate.c -o $@ + +$(BUILD)/libpure-execution.a: $(BUILD)/runtime.o $(BUILD)/evaluate.o + $(AR) rcs $@ $^ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-program: test-program.c program.c evaluate.c program.h store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-program.c program.c evaluate.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-fields: test-fields.c program.c evaluate.c program.h store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-fields.c program.c evaluate.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-runtime: test-runtime.c program.c $(CORE) $(HOST) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-runtime.c program.c $(CORE) $(HOST) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-splice: test-splice.c program.c $(CORE) $(HOST) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-splice.c program.c $(CORE) $(HOST) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/core-layout: core-layout.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) core-layout.c $(LDFLAGS) $(LDLIBS) -o $@ + +# Run on the release build, separately from sanitizer-instrumented objects. +check-core: core $(BUILD)/core-layout + python3 check_core.py --archive $(BUILD)/libpure-execution.a --layout $(BUILD)/core-layout $(if $(CORE_EVIDENCE),--output "$(CORE_EVIDENCE)") + +check: $(BUILD)/test-flow $(BUILD)/test-program $(BUILD)/test-fields $(BUILD)/test-runtime $(BUILD)/test-splice $(BUILD)/pure-edit $(BUILD)/pure-connections + $(BUILD)/test-flow + $(BUILD)/test-program + $(BUILD)/test-fields + python3 check.py --server $(BUILD)/pure-connections --runtime-test $(BUILD)/test-runtime --splice-test $(BUILD)/test-splice --editor $(BUILD)/pure-edit $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check.py new file mode 100644 index 0000000..4538995 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +"""Exercise local TCP, immutable byte extents, and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import fcntl +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import struct +import subprocess +import sys +import tempfile +import time +from check_edits import check_edits + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, storage, budget=4 * 1024 * 1024, timeout=5000, program=None): + self.process = subprocess.Popen([str(binary), '--store', str(storage), '--port', '0', + '--program', str(program or ROOT / 'examples/storage.pure'), + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory, editor=None): + storage = directory / 'objects' + checks, records = [], [] + with serving(binary, storage) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + with (storage / '.writer.lock').open('r+b') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + fcntl.lockf(lock, fcntl.LOCK_UN) + broken = storage / 'obj-broken' + broken.write_bytes(b'incomplete') + assert rpc(server, 'GET', 'broken')[0] == 500 + assert rpc(server, 'PUT', 'broken', b'failure', 0)[0] == 500 + assert broken.read_bytes() == b'incomplete' + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('writer contention and invalid extent fail without overwriting data') + + with serving(binary, storage) as second_writer: + with ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(rpc, target, 'PUT', 'race', value, 0) + for target, value in [(server, b'first'), (second_writer, b'second')]] + statuses = [task.result()[0] for task in tasks] + assert statuses.count(200) == 1 and all(status in [200, 412, 503] for status in statuses) + status, headers, body = rpc(server, 'GET', 'race') + assert status == 200 and headers['ETag'] == '"1"' and body in [b'first', b'second'] + records.append(second_writer.stats) + checks.append('two native writers cannot both create the same expected revision') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the immutable file whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock storage. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + wide = storage / 'obj-wide_revision' + raw = wide.read_bytes() + wide.write_bytes(raw[:8] + struct.pack('= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + prepared = authored_connections(binary, directory, storage, records, checks) + fields = field_connections(binary, directory, storage, records, checks) + edits = check_edits(editor, binary, ROOT, directory, serving, rpc, header, records, checks) if editor else None + return {'kind': 'prepared-connection-correctness-not-performance', 'checks': checks, + 'prepared_connections': prepared, + 'field_connections': fields, + 'document_edits': edits, + 'server_runs': records, 'storage': server.ready['storage']} + + +def authored_connections(binary, directory, storage, records, checks): + get = 'on get { let o = store.read(request.key); reply(o, o.body); }' + put = 'on put { let s = store.write(request.key, request.revision, request.body); reply(s); }' + + def author(name, source): + path = directory / name + path.write_text(source) + return path + + def explain(path): + return json.loads(subprocess.check_output([str(binary), '--check-program', str(path)], text=True)) + + with serving(binary, storage) as writer: + assert rpc(writer, 'PUT', 'short_parent', bytes(range(48)), 0)[0] == 200 + assert rpc(writer, 'PUT', 'short_unused', bytes(range(80)), 0)[0] == 200 + records.append(writer.stats) + excerpt = ROOT / 'examples/excerpt.pure' + prepared = {'excerpt': explain(excerpt)} + assert prepared['excerpt']['read'] == { + 'to_end': False, 'offset': 32, 'length': 16, 'minimum_source_bytes': 80} + with serving(binary, storage, budget=16, program=excerpt) as selected: + status, headers, body = rpc(selected, 'GET', 'large') + assert status == 200 and body == bytes(range(32, 48)) and headers['ETag'] == '"3"' + # Final [32,48) fits, but the authored parent [16,80) does not. + assert rpc(selected, 'GET', 'short_parent')[0] == 413 + assert rpc(selected, 'GET', 'missing')[0] == 404 + assert selected.stats['peak_retained_payload_bytes'] == 16 + records.append(selected.stats) + checks.append('authored nested read maps only final demand while preserving parent bounds and revision') + + unused = author('unused.pure', 'on get { let o = store.read(request.key); ' + 'let unused = range(o.body, 0, 96); let body = range(o.body, 32, 16); reply(o, body); } ' + put) + with serving(binary, storage, budget=16, program=unused) as guarded: + assert rpc(guarded, 'GET', 'short_unused')[0] == 413 + assert rpc(guarded, 'GET', 'large')[2] == bytes(range(32, 48)) + records.append(guarded.stats) + checks.append('unused checked view still fails even when response range would be valid') + + select_write = author('write.pure', get + ' on put { let a = range(request.body, 2, 10); ' + 'let b = range(a, 3, 4); let alias = b; ' + 'let saved = store.write(request.key, request.revision, alias); reply(saved, b); }') + prepared['write'] = explain(select_write) + with serving(binary, storage, program=select_write) as writer: + status, headers, body = rpc(writer, 'PUT', 'selected', b'0123456789AB', 0) + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'GET', 'selected')[2] == b'5678' + assert rpc(writer, 'PUT', 'selected', b'012345678', 1)[0] == 413 + status, headers, body = rpc(writer, 'GET', 'selected') + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'PUT', 'selected', b'0123456789AB', 0)[0] == 412 + records.append(writer.stats) + checks.append('authored write and reply share selected input; invalid parent prevents the storage effect') + + metadata = author('metadata.pure', 'on get { let o = store.read(request.key); reply(o); } ' + put) + with serving(binary, storage, budget=1, program=metadata) as meta: + status, headers, body = rpc(meta, 'GET', 'large') + assert status == 200 and headers['ETag'] == '"3"' and body == b'' + assert rpc(meta, 'GET', 'absent')[0] == 404 + assert rpc(meta, 'GET', 'broken')[0] == 500 + assert meta.stats['peak_retained_payload_bytes'] == 0 + records.append(meta.stats) + checks.append('body-free response preserves read existence and malformed-object errors') + + echo = author('echo.pure', get + ' on put { let body = request.body; ' + 'let saved = store.write(request.key, request.revision, body); reply(saved, body); }') + payload = bytes(range(256)) * 4096 + with serving(binary, storage, budget=len(payload), program=echo) as echoed: + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo', len(payload), b'If-None-Match: *\r\n') + payload) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.read(1) == payload[:1] + assert rpc(echoed, 'GET', 'binary')[0] == 503 + assert response.read() == payload[1:] + response.close() + assert rpc(echoed, 'GET', 'binary')[2] == b'new' + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo_abort', len(payload), b'If-None-Match: *\r\n') + payload) + assert sock.recv(1) + assert rpc(echoed, 'GET', 'binary')[0] == 503 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + deadline = time.monotonic() + 3 + while rpc(echoed, 'GET', 'binary')[0] == 503: + assert time.monotonic() < deadline + time.sleep(0.01) + assert rpc(echoed, 'GET', 'echo_abort')[2] == payload + records.append(echoed.stats) + checks.append('input-backed response survives partial send and releases on finish or abort without undoing commit') + + bad = author('invalid.pure', get + get + put) + untouched = directory / 'must_not_exist' + rejected = subprocess.run([str(binary), '--program', str(bad), '--store', str(untouched)], + capture_output=True, text=True, timeout=10) + assert rejected.returncode == 2 and 'duplicate handler' in rejected.stderr and not untouched.exists() + checks.append('invalid authored program fails preparation before creating storage or accepting requests') + return prepared + + +def field_connections(binary, directory, storage, records, checks): + program = ROOT / 'examples/selection.pure' + plans = {'selection': json.loads(subprocess.check_output( + [str(binary), '--check-program', str(program)], text=True))} + assert plans['selection']['runtime_view_steps'] == 1 + assert plans['selection']['steps'][0] == { + 'source_step': 0, 'source': {'offset': 8, 'length': 0, 'to_end': True}, + 'offset': {'value': 0, 'from_u32le': True}, 'length': {'value': 4, 'from_u32le': True}} + document = '처음🙂 Pure는 필드를 연결한다.\n끝' + document_file = directory / 'document.txt' + document_file.write_bytes(document.encode('utf-8')) + with serving(binary, storage, program=program) as selected: + command = [sys.executable, str(ROOT / 'examples/select-text.py'), str(document_file), + '--key', 'text_selection', '--start', '2', '--end', '12', '--url', f'http://127.0.0.1:{selected.port}'] + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, (result.stdout, result.stderr) + expected = document[2:12].encode('utf-8') + assert json.loads(result.stdout) == {'revision': '1', 'selected': document[2:12], 'stored_bytes': len(expected)} + assert rpc(selected, 'GET', 'text_selection')[2] == expected + checks.append('text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes') + + # Validation precedes the write: even a stale header cannot expose or + # commit partial input, and prior content/revision survive all failures. + invalid = [b'\0' * n for n in range(8)] + [ + struct.pack('= 3} + undefined = {line.split()[-1] for line in run('nm', '-u', archive).splitlines() + if len(line.split()) == 2 and line.split()[0] == 'U'} + external = sorted(undefined - defined) + failures = [] + if sorted(members) != sorted(budget['archive_members']): + failures.append('unexpected archive members') + for name, value in sizes.items(): + if value > budget[f'max_{name}_bytes']: + failures.append(f'{name}: {value} > {budget[f"max_{name}_bytes"]}') + for name, maximum in budget['layout_max_bytes'].items(): + if layout[name] > maximum: + failures.append(f'{name}: {layout[name]} > {maximum}') + extra = set(external) - set(budget['external_symbols_allowed']) + if extra: + failures.append(f'unexpected dependencies: {sorted(extra)}') + result = {'passed': not failures, 'failures': failures, 'budget': budget, + 'layout': layout, 'sections': sizes, 'members': members, + 'external_symbols': external, 'size_output': size_output, + 'archive_sha256': hashlib.sha256(archive.read_bytes()).hexdigest(), + 'layout_binary_sha256': hashlib.sha256(args.layout.resolve().read_bytes()).hexdigest(), + 'budget_sha256': hashlib.sha256(args.budget.read_bytes()).hexdigest(), + 'platform': platform.platform(), 'size_tool': run('size', '--version').splitlines()[0]} + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + if failures: + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check_edits.py b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check_edits.py new file mode 100644 index 0000000..1a6dc53 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/check_edits.py @@ -0,0 +1,108 @@ +"""Real native automation and HTTP clients editing the same authored document.""" +from concurrent.futures import ThreadPoolExecutor +import json +import socket +import struct +import subprocess + + +def check_edits(editor, binary, root, directory, serving, rpc, header, records, checks): + storage = directory / 'edited-objects' + program = root / 'examples/editor.pure' + file = directory / 'insertion.bin' + + def local(*args, body=None, code=0, source=program, budget=None): + if body is not None: + file.write_bytes(body) + args = (*args, str(file)) + command = [str(editor), '--program', str(source), '--store', str(storage)] + if budget is not None: + command += ['--budget', str(budget)] + result = subprocess.run(command + [str(arg) for arg in args], capture_output=True, timeout=20) + assert result.returncode == code, (command, args, result.stdout, result.stderr) + return result + + initial = 'Pure에서 안녕 🌏\n'.encode() + created = local('edit', 'note', 0, 0, 0, body=initial) + assert json.loads(created.stdout)['revision'] == '1' + start = initial.index('안녕'.encode()) + inserted = '반가워'.encode() + expected = initial[:start] + inserted + initial[start + len('안녕'.encode()):] + local('edit', 'note', 1, start, len('안녕'.encode()), body=inserted) + shown = local('show', 'note') + assert shown.stdout == expected and json.loads(shown.stderr)['revision'] == '2' + checks.append('native authored byte edit and fresh native process restore UTF-8 content') + stale = local('edit', 'note', 1, 0, 0, body=b'bad', code=3) + assert json.loads(stale.stderr) == {'error': 'conflict', 'revision': '2'} + local('edit', 'note', 2, 0xffffffff, 0, body=b'bad', code=2) + assert local('show', 'note').stdout == expected + checks.append('native stale revision and invalid byte position preserve the committed document') + + reordered = directory / 'edit-reordered.pure' + reordered.write_text('layout E { tag: bytes[1]; remove: u32le; start: u32le; body: bytes; } ' + 'on get { let o = store.read(request.key); reply(o, o.body); } ' + 'on put { let e = view(E, request.body); ' + 'let s = store.splice(request.key, request.revision, e.start, e.remove, e.body); reply(s, e.body); }') + local('edit', 'note', 2, len(expected), 0, body=b'!', source=reordered) + expected += b'!' + assert local('show', 'note', source=reordered).stdout == expected + checks.append('native convenience editor derives unaligned reordered field positions from the authored program') + + custom = directory / 'edit-custom.pure' + custom.write_text('on get { let o = store.read(request.key); reply(o, o.body); } ' + 'on put { let s = store.splice(request.key, request.revision, 0, 0, request.body); reply(s); }') + local('edit', 'custom', 0, 0, 0, body=b'ignored', source=custom, code=2) + applied = local('apply', 'custom', 0, body=b'custom input', source=custom) + assert json.loads(applied.stdout) == {'revision': '1', 'reply_bytes': 0} + assert local('show', 'custom', source=custom).stdout == b'custom input' + checks.append('raw native apply runs another authored input contract without changing the executor') + + with serving(binary, storage, program=program) as server: + assert rpc(server, 'GET', 'note')[2] == expected + status, headers, body = rpc(server, 'PUT', 'note', struct.pack(' + +/* Host-side measurement tool; never linked into libpure-execution.a. */ +int main(void) { + printf("{\"domain_bytes\":%zu,\"work_slot_bytes\":%zu,\"ticket_bytes\":%zu," + "\"prepared_program_bytes\":%zu,\"pointer_bytes\":%zu}\n", + sizeof(PureRuntime), sizeof(PureWork), sizeof(PureTicket), + sizeof(PureProgram), sizeof(void *)); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/edit.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/edit.c new file mode 100644 index 0000000..4daad14 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/edit.c @@ -0,0 +1,153 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include + +/* Native automation entry for the same prepared document program as the + * network adapter. No socket or body serialization in the local execution. */ +static bool number(const char *text, uint64_t limit, uint64_t *out) { + if (!*text) return false; + uint64_t value = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > limit || value > (limit - digit) / 10) return false; + value = value * 10 + digit; + } + *out = value; + return true; +} + +static const char *message(PureResult status) { + switch (status) { + case PURE_OK: return "ok"; + case PURE_NOT_FOUND: return "not_found"; + case PURE_CONFLICT: return "conflict"; + case PURE_BUSY: return "busy"; + case PURE_BUDGET: return "payload_budget"; + case PURE_LIMIT: return "limit"; + case PURE_UNCERTAIN: return "durability_unknown"; + case PURE_DENIED: return "denied"; + case PURE_ALLOCATION: return "allocation"; + case PURE_INVALID: return "invalid"; + default: return "failed"; + } +} + +static void put32(unsigned char *to, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) to[i] = (unsigned char)(value >> (8 * i)); +} + +/* The convenience command fills the program's two numeric splice arguments + * and its trailing insertion bytes. A more general input uses apply. */ +static bool edit_layout(const PureProgram *p, size_t *prefix) { + if (!p->splice || !p->write.to_end || p->write_step || p->input_steps || + !p->splice_offset.from_u32le || !p->splice_remove.from_u32le) return false; + size_t start = p->splice_offset.value, remove = p->splice_remove.value; + *prefix = p->write.offset; + if (*prefix > PURE_OBJECT_LIMIT || p->input_required > *prefix || start > *prefix || + remove > *prefix || *prefix - start < 4 || *prefix - remove < 4) return false; + return start < remove ? remove - start >= 4 : start - remove >= 4; +} + +int main(int argc, char **argv) { + const char *program_path = NULL, *store_path = NULL; + size_t budget = 4u * 1024u * 1024u; + int at = 1; + for (; at + 1 < argc && !strncmp(argv[at], "--", 2); at += 2) { + uint64_t value; + if (!strcmp(argv[at], "--program")) program_path = argv[at + 1]; + else if (!strcmp(argv[at], "--store")) store_path = argv[at + 1]; + else if (!strcmp(argv[at], "--budget") && number(argv[at + 1], SIZE_MAX, &value)) budget = (size_t)value; + else goto usage; + } + if (!program_path || !store_path || at >= argc) goto usage; + bool show = !strcmp(argv[at], "show"), edit = !strcmp(argv[at], "edit"); + if ((!show && !edit && strcmp(argv[at], "apply")) || + argc - at != (show ? 2 : edit ? 6 : 4)) goto usage; + uint64_t expected = 0, offset = 0, remove = 0; + if (!show && !number(argv[at + 2], INT64_MAX, &expected)) goto usage; + if (edit && (!number(argv[at + 3], UINT32_MAX, &offset) || !number(argv[at + 4], UINT32_MAX, &remove))) goto usage; + PureProgram program; + char error[160]; + if (!pure_program_load(program_path, &program, error, sizeof(error))) { + fprintf(stderr, "Program: %s\n", error); return 2; + } + size_t prefix = 0; + if (edit && !edit_layout(&program, &prefix)) { + fputs("edit requires two distinct u32le splice fields and trailing insertion bytes; use apply for a custom input\n", stderr); + return 2; + } + FILE *file = NULL; + size_t length = 0; + if (!show) { + file = fopen(argv[at + (edit ? 5 : 3)], "rb"); + if (!file) { perror("input"); return 2; } + long bytes; + if (fseek(file, 0, SEEK_END) || (bytes = ftell(file)) < 0 || + (uintmax_t)bytes > PURE_OBJECT_LIMIT - prefix || fseek(file, 0, SEEK_SET)) { + fputs("input must be a seekable file within the request size limit\n", stderr); + fclose(file); return 2; + } + length = (size_t)bytes; + } + if (pure_store_init(store_path) != PURE_OK) { + if (file) fclose(file); + fputs("cannot initialize store\n", stderr); return 2; + } + PureRuntime runtime; + PureWork work[1]; + if (!pure_runtime_init(&runtime, work, 1, &program, pure_store_host(store_path), budget)) { + if (file) fclose(file); + return 2; + } + PureTicket ticket; + PureResult admitted = pure_runtime_accept(&runtime, show ? PURE_INPUT_READ : PURE_INPUT_WRITE, + argv[at + 1], (int64_t)expected, prefix + length, &ticket); + if (admitted != PURE_OK) { + if (file) fclose(file); + fprintf(stderr, "%s\n", message(admitted)); return 2; + } + if (!show) { + unsigned char *input = pure_runtime_ingress(ticket, NULL); + if (prefix) memset(input, 0, prefix); + if (edit) { + put32(input + program.splice_offset.value, (uint32_t)offset); + put32(input + program.splice_remove.value, (uint32_t)remove); + } + bool valid = !length || fread(input + prefix, 1, length, file) == length; + if (fgetc(file) != EOF || ferror(file)) valid = false; + if (fclose(file)) valid = false; + if (!valid) { + pure_runtime_release(ticket); + fputs("input changed or could not be read; nothing was applied\n", stderr); return 2; + } + if (!pure_runtime_ready(ticket)) { pure_runtime_release(ticket); return 2; } + } + PureOutcome outcome; + while (!pure_runtime_result(ticket, &outcome)) { + if (!pure_runtime_step(&runtime)) { pure_runtime_release(ticket); return 2; } + } + int status = 0; + if (outcome.status != PURE_OK) { + fprintf(stderr, "{\"error\":\"%s\",\"revision\":\"%" PRId64 "\"}\n", message(outcome.status), outcome.revision); + status = outcome.status == PURE_CONFLICT ? 3 : 2; + } else if (show) { + if (outcome.body.size && fwrite(outcome.body.data, 1, outcome.body.size, stdout) != outcome.body.size) status = 2; + fprintf(stderr, "{\"revision\":\"%" PRId64 "\",\"bytes\":%zu}\n", outcome.revision, outcome.body.size); + } else { + if (printf("{\"revision\":\"%" PRId64 "\",\"reply_bytes\":%zu}\n", outcome.revision, outcome.body.size) < 0) status = 2; + } + if (fflush(stdout)) status = 2; + pure_runtime_release(ticket); + return status; +usage: + fputs("pure-edit --program FILE --store DIRECTORY [--budget BYTES] show KEY\n" + "pure-edit --program FILE --store DIRECTORY [--budget BYTES] edit KEY REVISION BYTE_OFFSET REMOVE_BYTES INSERT_FILE\n" + "pure-edit --program FILE --store DIRECTORY [--budget BYTES] apply KEY REVISION INPUT_FILE\n" + "edit uses the prepared program's field locations; apply accepts its raw input bytes.\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/evaluate.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/evaluate.c new file mode 100644 index 0000000..88ffae7 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/evaluate.c @@ -0,0 +1,53 @@ +#include "program.h" +#include + +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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/editor.pure b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/editor.pure new file mode 100644 index 0000000..4de78bc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/editor.pure @@ -0,0 +1,19 @@ +// A revision-checked byte edit. The input carries only the replacement. +// UTF-8 cursor/grapheme positions are translated to byte ranges by the UI. +layout Edit { + start: u32le; + remove: u32le; + insert: bytes; +} + +on get { + let document = store.read(request.key); + reply(document, document.body); +} + +on put { + let edit = view(Edit, request.body); + let saved = store.splice(request.key, request.revision, + edit.start, edit.remove, edit.insert); + reply(saved, edit.insert); +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/excerpt.pure b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/excerpt.pure new file mode 100644 index 0000000..622f9bb --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/excerpt.pure @@ -0,0 +1,13 @@ +// Read bytes 32..47 of an object that must contain the complete 64-byte window. +// Preparation keeps the size check for 16..79, and maps only 32..47 for reply. +on get { + let object = store.read(request.key); + let window = range(object.body, 16, 64); + let excerpt = range(window, 16, 16); + reply(object, excerpt); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/select-text.py b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/select-text.py new file mode 100644 index 0000000..ffaf80b --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/select-text.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Save a UTF-8 document selection through selection.pure (character indices).""" +import argparse +import http.client +import json +from pathlib import Path +import re +import struct +from urllib.parse import urlsplit + +LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('file', type=Path) + parser.add_argument('--key', required=True) + parser.add_argument('--start', type=int, required=True, help='first Unicode code point, starting at 0') + parser.add_argument('--end', type=int, required=True, help='exclusive Unicode code point boundary') + parser.add_argument('--revision', type=int, default=0, help='0 creates; otherwise expected stored revision') + parser.add_argument('--url', default='http://127.0.0.1:8767') + args = parser.parse_args() + url = urlsplit(args.url) + try: + port = url.port or 80 + except ValueError as error: + parser.error(str(error)) + if (url.scheme != 'http' or url.hostname not in ['localhost', '127.0.0.1'] or + url.username is not None or url.password is not None or url.path not in ['', '/'] or + url.query or url.fragment): + parser.error('use a local HTTP server origin') + if not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', args.key) or not 0 <= args.revision <= 2**63 - 1: + parser.error('invalid object key or revision') + try: + with args.file.open('rb') as source: + document = source.read(LIMIT - 8 + 1) + if len(document) > LIMIT - 8: + parser.error('document exceeds the prototype request limit') + text = document.decode('utf-8') + except (OSError, UnicodeError) as error: + parser.error(str(error)) + if not 0 <= args.start <= args.end <= len(text): + parser.error('selection is outside the document') + start = len(text[:args.start].encode('utf-8')) + selected = text[args.start:args.end].encode('utf-8') + # The boundary layout is explicit; storage receives only selected bytes. + payload = struct.pack(' 2**63 - 1: + parser.error('response has no valid stored revision (write may have occurred)') + except (OSError, http.client.HTTPException) as error: + parser.error(f'{error}; storage outcome is unknown if the request was sent') + finally: + connection.close() + print(json.dumps({'revision': revision[1:-1], 'selected': selected.decode('utf-8'), + 'stored_bytes': len(selected)}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/selection.pure b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/selection.pure new file mode 100644 index 0000000..7d9d4ec --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/selection.pure @@ -0,0 +1,19 @@ +// This is an input boundary layout, not a required storage or RAM model. +// The client sends two little-endian u32 values followed by document bytes. +layout Selection { + start: u32le; + count: u32le; + document: bytes; +} + +on get { + let saved = store.read(request.key); + reply(saved, saved.body); +} + +on put { + let input = view(Selection, request.body); + let selected = range(input.document, input.start, input.count); + let saved = store.write(request.key, request.revision, selected); + reply(saved, selected); +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/storage.pure b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/storage.pure new file mode 100644 index 0000000..ff54ea3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/examples/storage.pure @@ -0,0 +1,11 @@ +// The host supplies request bytes, an object name, and the expected revision. +// Failed storage results propagate to the host's response without executing reply. +on get { + let object = store.read(request.key); + reply(object, object.body); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.c new file mode 100644 index 0000000..8680782 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.c @@ -0,0 +1,366 @@ +#include "program.h" +#include "store.h" +#include +#include +#include + +enum { NAME = 256, NUMBER, SYMBOLS = 32, LAYOUTS = 4, FIELDS = 16 }; +typedef enum { BYTE_VALUE, RESULT_VALUE, LAYOUT_VALUE, INDEX_VALUE } ValueKind; +typedef struct { + char name[32]; + ValueKind kind; + PureRegion region; + size_t step, layout; + PureIndex index; +} Symbol; +typedef struct { + char name[32]; + size_t offset, length; + bool number, tail; +} Field; +typedef struct { + char name[32]; + Field fields[FIELDS]; + size_t count, prefix; + bool tail; +} Layout; +typedef struct { + const char *source; + size_t length, at, line, token_line; + int token; + char name[32]; + size_t number; + char *error; + size_t error_size; + bool failed, put, effect; + Symbol symbols[SYMBOLS]; + size_t count, layout_count; + Layout layouts[LAYOUTS]; + PureProgram program; +} Parser; + +static bool reject(Parser *p, const char *message) { + if (!p->failed && p->error_size) snprintf(p->error, p->error_size, "line %zu: %s", p->token_line, message); + p->failed = true; + return false; +} + +static bool letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static bool next(Parser *p) { + while (p->at < p->length) { + char c = p->source[p->at]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (c == '\n') ++p->line; + ++p->at; + } else if (c == '/' && p->at + 1 < p->length && p->source[p->at + 1] == '/') { + while (p->at < p->length && p->source[p->at] != '\n') ++p->at; + } else break; + } + p->token_line = p->line; + p->name[0] = 0; + p->token = 0; + if (p->at == p->length) return true; + char c = p->source[p->at++]; + if (letter(c)) { + size_t start = p->at - 1; + while (p->at < p->length && (letter(p->source[p->at]) || + (p->source[p->at] >= '0' && p->source[p->at] <= '9'))) ++p->at; + size_t size = p->at - start; + if (size >= sizeof(p->name)) return reject(p, "name exceeds 31 bytes"); + memcpy(p->name, p->source + start, size); + p->name[size] = 0; + p->token = NAME; + } else if (c >= '0' && c <= '9') { + size_t value = (size_t)(c - '0'); + while (p->at < p->length && p->source[p->at] >= '0' && p->source[p->at] <= '9') { + unsigned digit = (unsigned)(p->source[p->at++] - '0'); + if (value > (SIZE_MAX - digit) / 10) return reject(p, "integer overflow"); + value = value * 10 + digit; + } + p->number = value; + p->token = NUMBER; + } else if (c && strchr("{}().,=;:[]", c)) p->token = (unsigned char)c; + else return reject(p, "unexpected character"); + return true; +} + +static bool is_word(Parser *p, const char *word) { + return p->token == NAME && !strcmp(p->name, word); +} + +static bool word(Parser *p, const char *expected) { + return is_word(p, expected) ? next(p) : reject(p, expected); +} + +static bool token(Parser *p, int expected) { + return p->token == expected ? next(p) : reject(p, "unexpected token"); +} + +static Symbol *find(Parser *p, const char *name) { + for (size_t i = 0; i < p->count; ++i) + if (!strcmp(name, p->symbols[i].name)) return &p->symbols[i]; + return NULL; +} + +static bool field(Parser *p, const char *object, const char *name) { + return word(p, object) && token(p, '.') && word(p, name); +} + +static bool require_bytes(Parser *p, const Symbol *value) { + return value->kind == BYTE_VALUE || reject(p, "expected bytes; select a byte field"); +} + +static void require_length(Parser *p, size_t required) { + size_t *minimum = p->put ? &p->program.input_required : &p->program.read_required; + if (required > *minimum) *minimum = required; +} + +static Layout *find_layout(Parser *p, const char *name) { + for (size_t i = 0; i < p->layout_count; ++i) + if (!strcmp(name, p->layouts[i].name)) return &p->layouts[i]; + return NULL; +} + +static bool layout_definition(Parser *p) { + if (p->layout_count == LAYOUTS) return reject(p, "program exceeds 4 layouts"); + if (!next(p)) return false; + if (p->token != NAME || find_layout(p, p->name)) return reject(p, "expected a unique layout name"); + Layout *layout = &p->layouts[p->layout_count]; + strcpy(layout->name, p->name); + if (!next(p) || !token(p, '{')) return false; + while (p->token != '}') { + if (layout->count == FIELDS) return reject(p, "layout exceeds 16 fields"); + if (layout->tail) return reject(p, "variable bytes must be the last field"); + if (p->token != NAME) return reject(p, "expected a field name"); + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(layout->fields[i].name, p->name)) return reject(p, "duplicate field"); + Field *f = &layout->fields[layout->count]; + strcpy(f->name, p->name); + f->offset = layout->prefix; + if (!next(p) || !token(p, ':')) return false; + if (is_word(p, "u32le")) { + f->number = true; + f->length = 4; + if (!next(p)) return false; + } else if (is_word(p, "bytes")) { + if (!next(p)) return false; + if (p->token == '[') { + if (!next(p)) return false; + if (p->token != NUMBER) return reject(p, "fixed bytes requires a constant size"); + f->length = p->number; + if (!next(p) || !token(p, ']')) return false; + } else { f->tail = true; layout->tail = true; } + } else return reject(p, "supported field types: u32le, bytes[N], bytes"); + if (f->length > PURE_OBJECT_LIMIT - layout->prefix) return reject(p, "layout exceeds object bound"); + layout->prefix += f->length; + ++layout->count; + if (!token(p, ';')) return false; + } + if (!layout->count) return reject(p, "empty layout"); + ++p->layout_count; + return token(p, '}'); +} + +static bool reference(Parser *p, Symbol *out) { + if (is_word(p, "request")) { + if (!p->put) return reject(p, "request.body is only available in on put"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return field(p, "request", "body"); + } + Symbol *symbol = p->token == NAME ? find(p, p->name) : NULL; + if (!symbol) return reject(p, "unknown value"); + *out = *symbol; + if (!next(p)) return false; + if (p->token != '.') return true; + if (!next(p)) return false; + if (symbol->kind == RESULT_VALUE) { + if (p->put) return reject(p, "write result has no body; use the input value"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return word(p, "body"); + } + if (symbol->kind != LAYOUT_VALUE || p->token != NAME) return reject(p, "expected a layout field"); + Layout *layout = &p->layouts[symbol->layout]; + Field *f = NULL; + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(p->name, layout->fields[i].name)) f = &layout->fields[i]; + if (!f) return reject(p, "unknown layout field"); + size_t offset = symbol->region.offset + f->offset; + *out = (Symbol){.kind = f->number ? INDEX_VALUE : BYTE_VALUE}; + if (f->number) out->index = (PureIndex){.value = offset, .from_u32le = true}; + else out->region = (PureRegion){.offset = offset, + .length = f->tail ? (symbol->region.to_end ? 0 : symbol->region.length - f->offset) : f->length, + .to_end = f->tail && symbol->region.to_end}; + return next(p); +} + +static bool byte_reference(Parser *p, Symbol *out) { + return reference(p, out) && require_bytes(p, out); +} + +static bool index_value(Parser *p, PureIndex *out) { + if (p->token == NUMBER) { + *out = (PureIndex){.value = p->number}; + return next(p); + } + Symbol value; + if (!reference(p, &value)) return false; + if (value.kind != INDEX_VALUE) return reject(p, "range requires an integer or u32le field"); + *out = value.index; + return true; +} + +static bool layout_view(Parser *p, Symbol *out) { + if (!next(p) || !token(p, '(')) return false; + Layout *layout = p->token == NAME ? find_layout(p, p->name) : NULL; + if (!layout) return reject(p, "unknown layout"); + if (!next(p) || !token(p, ',')) return false; + Symbol source; + if (!byte_reference(p, &source) || !token(p, ')')) return false; + if (source.step) return reject(p, "layout view requires a statically located source"); + size_t bound = source.region.to_end ? PURE_OBJECT_LIMIT - source.region.offset : source.region.length; + if (layout->prefix > bound) return reject(p, "layout exceeds source bound"); + require_length(p, source.region.offset + layout->prefix); + *out = (Symbol){.kind = LAYOUT_VALUE, .region = source.region, .layout = (size_t)(layout - p->layouts)}; + if (!layout->tail) { out->region.length = layout->prefix; out->region.to_end = false; } + return true; +} + +static bool view(Parser *p, Symbol *out) { + if (is_word(p, "view")) return layout_view(p, out); + if (!is_word(p, "range")) return reference(p, out); + Symbol parent; + PureIndex offset, length; + if (!next(p) || !token(p, '(') || !byte_reference(p, &parent) || !token(p, ',') || + !index_value(p, &offset) || !token(p, ',') || !index_value(p, &length) || !token(p, ')')) return false; + bool dynamic = parent.step || offset.from_u32le || length.from_u32le; + *out = (Symbol){.kind = BYTE_VALUE}; + if (dynamic) { + if (!p->put) return reject(p, "data-dependent ranges currently require PUT input"); + if (p->program.input_steps == PURE_DYNAMIC_LIMIT) return reject(p, "input exceeds 8 dynamic ranges"); + if ((!offset.from_u32le && offset.value > PURE_OBJECT_LIMIT) || + (!length.from_u32le && length.value > PURE_OBJECT_LIMIT)) return reject(p, "range exceeds object bound"); + PureRangeStep *step = &p->program.steps[p->program.input_steps++]; + *step = (PureRangeStep){.source = parent.region, .source_step = parent.step, + .offset = offset, .length = length}; + out->step = p->program.input_steps; + } else { + size_t bound = parent.region.to_end ? PURE_OBJECT_LIMIT - parent.region.offset : parent.region.length; + if (offset.value > bound || length.value > bound - offset.value) return reject(p, "range exceeds its source bound"); + out->region = (PureRegion){.offset = parent.region.offset + offset.value, .length = length.value}; + require_length(p, out->region.offset + out->region.length); + } + ++p->program.range_definitions; + return true; +} + +static bool effect(Parser *p, Symbol *out) { + if (p->effect) return reject(p, "only one storage effect is supported per handler"); + if (!word(p, "store") || !token(p, '.')) return false; + bool splice = p->put && is_word(p, "splice"); + if (!word(p, p->put ? (splice ? "splice" : "write") : "read") || + !token(p, '(') || !field(p, "request", "key")) return false; + if (p->put) { + Symbol value; + if (!token(p, ',') || !field(p, "request", "revision") || !token(p, ',')) return false; + if (splice) { + if (!index_value(p, &p->program.splice_offset) || !token(p, ',') || + !index_value(p, &p->program.splice_remove) || !token(p, ',')) return false; + if ((!p->program.splice_offset.from_u32le && p->program.splice_offset.value > PURE_OBJECT_LIMIT) || + (!p->program.splice_remove.from_u32le && p->program.splice_remove.value > PURE_OBJECT_LIMIT)) + return reject(p, "splice exceeds object bound"); + p->program.splice = true; + } + if (!byte_reference(p, &value)) return false; + p->program.write = value.region; + p->program.write_step = value.step; + } + if (!token(p, ')')) return false; + out->kind = RESULT_VALUE; + p->effect = true; + return true; +} + +static bool handler(Parser *p) { + p->count = 0; + p->effect = false; + if (!token(p, '{')) return false; + while (is_word(p, "let")) { + if (p->put && p->effect) return reject(p, "all views must precede the storage effect; reply must follow it"); + if (p->count == SYMBOLS) return reject(p, "handler exceeds 32 definitions"); + if (!next(p)) return false; + if (p->token != NAME || is_word(p, "request") || is_word(p, "store") || + is_word(p, "range") || is_word(p, "view")) return reject(p, "expected a non-reserved definition name"); + if (find(p, p->name)) return reject(p, "duplicate definition"); + char name[32]; + strcpy(name, p->name); + Symbol symbol = {0}; + if (!next(p) || !token(p, '=')) return false; + if (is_word(p, "store")) { + if (!effect(p, &symbol)) return false; + } else { + if (!view(p, &symbol)) return false; + ++p->program.view_definitions; + } + if (!token(p, ';')) return false; + strcpy(symbol.name, name); + p->symbols[p->count++] = symbol; + } + if (!p->effect) return reject(p, "handler requires its storage effect"); + if (!word(p, "reply") || !token(p, '(')) return false; + Symbol *result = p->token == NAME ? find(p, p->name) : NULL; + if (!result || result->kind != RESULT_VALUE) return reject(p, "reply requires this handler's storage result"); + if (!next(p)) return false; + Symbol body = {0}; + bool has_body = p->token == ','; + if (has_body && (!next(p) || !byte_reference(p, &body))) return false; + if (!token(p, ')') || !token(p, ';') || !token(p, '}')) return false; + if (p->put) { + p->program.reply = body.region; + p->program.reply_step = body.step; + p->program.reply_input = has_body; + } else p->program.read = body.region; + return true; +} + +bool pure_program_parse(const char *source, size_t length, PureProgram *out, + char *error, size_t error_size) { + Parser p = {.source = source, .length = length, .line = 1, .token_line = 1, + .error = error, .error_size = error_size}; + if (error_size) error[0] = 0; + if (length > PURE_PROGRAM_LIMIT) return reject(&p, "program exceeds 16 KiB"); + if (!next(&p)) return false; + while (is_word(&p, "layout")) if (!layout_definition(&p)) return false; + unsigned handlers = 0; + while (p.token) { + if (!word(&p, "on")) return false; + if (!is_word(&p, "get") && !is_word(&p, "put")) return reject(&p, "expected get or put handler"); + p.put = is_word(&p, "put"); + unsigned bit = p.put ? 2 : 1; + if (handlers & bit) return reject(&p, "duplicate handler"); + handlers |= bit; + if (!next(&p) || !handler(&p)) return false; + } + if (handlers != 3) return reject(&p, "both get and put handlers are required"); + *out = p.program; /* Invalid preparation never changes the installed plan. */ + return true; +} + +bool pure_program_load(const char *path, PureProgram *out, char *error, size_t error_size) { + FILE *file = fopen(path, "rb"); + if (!file) { + if (error_size) snprintf(error, error_size, "cannot open program"); + return false; + } + char source[PURE_PROGRAM_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool bad = ferror(file) != 0; + if (fclose(file)) bad = true; + if (bad) { + if (error_size) snprintf(error, error_size, "cannot read program"); + return false; + } + return pure_program_parse(source, length, out, error, error_size); +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.h b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/program.h new file mode 100644 index 0000000..c102eb1 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/result.h b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/result.h new file mode 100644 index 0000000..8abb0f2 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.c new file mode 100644 index 0000000..aa4ed05 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.c @@ -0,0 +1,273 @@ +#include "runtime.h" +#include + +#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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.h b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.h new file mode 100644 index 0000000..1bff082 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/runtime.h @@ -0,0 +1,88 @@ +#ifndef PURE_RUNTIME_H +#define PURE_RUNTIME_H +#include "program.h" +#include "result.h" +#include + +/* 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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/server.c new file mode 100644 index 0000000..50480c0 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/server.c @@ -0,0 +1,372 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include "program.h" +#include "store-port.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received; + unsigned char *body; + bool put; + int64_t expected; + PureTicket ticket; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + PureProgram program; + PureRuntime runtime; + PureWork work[SLOTS]; + size_t budget, active, peak_active; + uint64_t accepted, expired, prefix_copy_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + if (c->ticket.runtime) { + bool released = pure_runtime_release(c->ticket); + assert(released); + (void)released; + } + c->ticket = (PureTicket){0}; + c->body = NULL; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void answer(Connection *c, PureOutcome result) { + switch (result.status) { + case PURE_OK: respond(c, 200, "OK", "none", result.revision, result.body); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + respond(c, 412, "Precondition Failed", "conflict", result.revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "store_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_ALLOCATION: fail(c, 503, "Service Unavailable", "allocation"); break; + case PURE_CLOSED: fail(c, 503, "Service Unavailable", "stopped"); break; + case PURE_DENIED: fail(c, 403, "Forbidden", "denied"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + case PURE_UNCERTAIN: fail(c, 500, "Internal Server Error", "durability_unknown"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +static void run_request(Connection *c) { + if (c->put && !pure_runtime_ready(c->ticket)) abort(); + PureOutcome result; + while (!pure_runtime_result(c->ticket, &result)) + if (!pure_runtime_step(&c->server->runtime)) abort(); + c->body = NULL; /* Core now owns input until all consumers release it. */ + answer(c, result); +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + PureResult admitted = pure_runtime_accept(&c->server->runtime, + c->put ? PURE_INPUT_WRITE : PURE_INPUT_READ, c->key, c->expected, c->body_size, &c->ticket); + if (admitted != PURE_OK) { answer(c, (PureOutcome){.status = admitted}); return; } + if (!c->put) { run_request(c); return; } + c->body = pure_runtime_ingress(c->ticket, NULL); + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + const char *program_path = NULL; + bool explain = argc == 3 && !strcmp(argv[1], "--check-program"); + char program_error[160]; + if (explain) program_path = argv[2]; + for (int i = 1; !explain && i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--store")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--program")) program_path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!program_path || (!explain && !server.path)) goto usage; + if (!pure_program_load(program_path, &server.program, program_error, sizeof(program_error))) { + fprintf(stderr, "Program: %s\n", program_error); return 2; + } + if (explain) { + PureProgram *p = &server.program; + printf("{\"read\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"write\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"reply_input\":%s,\"view_definitions\":%zu,\"range_definitions\":%zu," + "\"runtime_view_steps\":%zu,\"write_step\":%zu,\"reply_step\":%zu,\"prepared_program_bytes\":%zu,\"steps\":[", + p->read.to_end ? "true" : "false", p->read.offset, p->read.length, p->read_required, + p->write.to_end ? "true" : "false", p->write.offset, p->write.length, p->input_required, + p->reply_input ? "true" : "false", p->view_definitions, p->range_definitions, + p->input_steps, p->write_step, p->reply_step, sizeof(*p)); + for (size_t i = 0; i < p->input_steps; ++i) { + const PureRangeStep *step = &p->steps[i]; + printf("%s{\"source_step\":%zu,\"source\":{\"offset\":%zu,\"length\":%zu,\"to_end\":%s}," + "\"offset\":{\"value\":%zu,\"from_u32le\":%s},\"length\":{\"value\":%zu,\"from_u32le\":%s}}", + i ? "," : "", step->source_step, step->source.offset, step->source.length, + step->source.to_end ? "true" : "false", step->offset.value, step->offset.from_u32le ? "true" : "false", + step->length.value, step->length.from_u32le ? "true" : "false"); + } + printf("],\"write_effect\":\"%s\",\"splice_offset\":{\"value\":%zu,\"from_u32le\":%s}," + "\"splice_remove\":{\"value\":%zu,\"from_u32le\":%s}}\n", + p->splice ? "splice" : "write", p->splice_offset.value, + p->splice_offset.from_u32le ? "true" : "false", p->splice_remove.value, + p->splice_remove.from_u32le ? "true" : "false"); + return 0; + } + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize byte store directory\n", stderr); return 1; } + if (!pure_runtime_init(&server.runtime, server.work, SLOTS, &server.program, + pure_store_host(server.path), server.budget)) return 1; + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"storage\":\"immutable-byte-extents-v1\"}\n", ntohs(address.sin_port)); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + pure_runtime_stop(&server.runtime); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.runtime.retained, server.runtime.peak_retained, server.budget, server.peak_active, + server.accepted, server.runtime.commits, server.runtime.gets, server.runtime.conflicts, server.runtime.budget_rejections, + server.expired, server.runtime.releases, server.prefix_copy_bytes, server.runtime.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --program FILE --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n" + "pure-connections --check-program FILE\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.c new file mode 100644 index 0000000..9860308 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.c @@ -0,0 +1,44 @@ +#include "store-port.h" +#include "store.h" +#include +#include + +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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.h b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store-port.h new file mode 100644 index 0000000..4c0d49d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.c new file mode 100644 index 0000000..4c8bdc4 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.c @@ -0,0 +1,258 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.h new file mode 100644 index 0000000..99a3ef5 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/store.h @@ -0,0 +1,44 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include "result.h" +#include + +#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/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-fields.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-fields.c new file mode 100644 index 0000000..63dfc3e --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-fields.c @@ -0,0 +1,153 @@ +#include "program.h" +#include +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static const char layout[] = "layout Envelope { marker: bytes[1]; start: u32le; count: u32le; text: bytes; }"; +static PureProgram parse(const char *source) { + PureProgram p; + char error[160]; + if (!pure_program_parse(source, strlen(source), &p, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return p; +} +static void invalid(const char *source) { + PureProgram p; + memset(&p, 0x5a, sizeof(p)); + unsigned char before[sizeof(p)]; + memcpy(before, &p, sizeof(p)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(*error && !memcmp(before, &p, sizeof(p))); +} +static void encode(unsigned char *at, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) at[i] = (unsigned char)(value >> (8 * i)); +} +int main(void) { + char source[8000]; + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let alias = input; let start = alias.start; let text = alias.text; " + "let selected = range(text, start, alias.count); let result = selected; " + "let saved = store.write(request.key, request.revision, result); reply(saved, selected); }", layout, get); + PureProgram p = parse(source); + CHECK(p.input_steps == 1 && p.input_required == 9 && p.write_step == 1 && p.reply_step == 1); + CHECK(p.steps[0].source.offset == 9 && p.steps[0].source.to_end); + CHECK(p.steps[0].offset.value == 1 && p.steps[0].offset.from_u32le); + CHECK(p.steps[0].length.value == 5 && p.steps[0].length.from_u32le); + unsigned char bytes[609]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 13); + PureSpan stored, returned; + for (size_t n = 0; n < 9; ++n) { + CHECK(!pure_program_input(&p, (PureSpan){bytes, n}, &stored, &returned)); + CHECK(!stored.size && !returned.size); + } + const uint32_t offsets[] = {0, 1, 255, 257, 599, 600, 601, UINT32_MAX}; + const uint32_t counts[] = {0, 1, 2, 260, 600, UINT32_MAX}; + size_t comparisons = 0; + for (size_t i = 0; i < sizeof(offsets) / sizeof(*offsets); ++i) + for (size_t j = 0; j < sizeof(counts) / sizeof(*counts); ++j) { + uint32_t start = offsets[i], count = counts[j]; + encode(bytes + 1, start); + encode(bytes + 5, count); + bool expected = start <= 600 && count <= 600 - start; + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned) == expected); + if (expected) { + CHECK(stored.size == count && returned.size == count && stored.data == returned.data); + CHECK(!count || stored.data == bytes + 9 + start); + } else CHECK(!stored.size && !returned.size); + ++comparisons; + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let first = range(input.text, input.start, input.count); let second = range(first, 1, 2); " + "let saved = store.write(request.key, request.revision, second); reply(saved, second); }", layout, get); + p = parse(source); + CHECK(p.input_steps == 2 && p.steps[1].source_step == 1); + encode(bytes + 1, 3); encode(bytes + 5, 4); + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + CHECK(stored.size == 2 && stored.data == bytes + 13); + encode(bytes + 5, 2); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let unused = range(input.text, input.start, input.count); " + "let saved = store.write(request.key, request.revision, input.text); reply(saved); }", layout, get); + p = parse(source); + encode(bytes + 1, UINT32_MAX); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); " + "let input = view(Envelope, o.body); reply(o, input.text); } %s", layout, put); + p = parse(source); + CHECK(p.read.to_end && p.read.offset == 9 && p.read_required == 9 && !p.input_steps); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let selection = range(input.text, 2, 3); let saved = store.write(request.key, request.revision, selection); reply(saved); }", layout, get); + p = parse(source); + CHECK(!p.input_steps && p.write.offset == 11 && p.write.length == 3 && p.input_required == 14); + snprintf(source, sizeof(source), "layout Empty { body: bytes; } %s on put { let input = view(Empty, request.body); " + "let saved = store.write(request.key, request.revision, input.body); reply(saved, input.body); }", get); + p = parse(source); + CHECK(pure_program_input(&p, (PureSpan){0}, &stored, &returned) && !stored.size && !returned.size); + + const char *bad_layout[] = { + "layout E {}", "layout E { x: bytes; y: u32le; }", "layout E { x: u32le; x: bytes; }", + "layout E { x: bytes[1449985]; }", "layout E { x: bytes[1449984]; y: u32le; }", + "layout E { x: bytes[184467440737095516160]; }", "layout E { x: u64le; }", + "layout E { x: u32le; } layout E { y: bytes; }" + }; + for (size_t i = 0; i < sizeof(bad_layout) / sizeof(*bad_layout); ++i) { + snprintf(source, sizeof(source), "%s %s %s", bad_layout[i], get, put); invalid(source); + } + const char *bad_input[] = { + "let input = view(Missing, request.body);", + "let input = view(Envelope, request.body); let x = input.missing;", + "let input = view(Envelope, request.body); let x = range(input.text, input.text, 1);", + "let short = range(request.body, 0, 8); let input = view(Envelope, short);", + "let input = view(Envelope, request.body); let x = range(input.text, input.start, 20); let y = view(Envelope, x);" + }; + for (size_t i = 0; i < sizeof(bad_input) / sizeof(*bad_input); ++i) { + snprintf(source, sizeof(source), "%s %s on put { %s let saved = store.write(request.key, request.revision, request.body); reply(saved); }", layout, get, bad_input[i]); + invalid(source); + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let saved = store.write(request.key, request.revision, input.start); reply(saved); }", layout, get); + invalid(source); + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); let input = view(Envelope, o.body); " + "let x = range(input.text, input.start, input.count); reply(o, x); } %s", layout, put); + invalid(source); + for (size_t count = 8; count <= 9; ++count) { + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); ", layout, get); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let v%zu = range(input.text, input.start, input.count); ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let saved = store.write(request.key, request.revision, input.text); reply(saved); }"); + if (count == 8) CHECK(parse(source).input_steps == 8); else invalid(source); + } + for (size_t count = 4; count <= 5; ++count) { + source[0] = 0; + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "layout L%zu { body: bytes; } ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "%s %s", get, put); + if (count == 4) (void)parse(source); else invalid(source); + } + for (size_t count = 16; count <= 17; ++count) { + strcpy(source, "layout L { "); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "f%zu: u32le; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "} %s %s", get, put); + if (count == 16) (void)parse(source); else invalid(source); + } + printf("{\"fields\":\"named-layouts-unaligned-u32le-tail-dynamic-ranges-aliases-failures-limits\"," + "\"data_boundary_cases\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..5ff028d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-flow.c @@ -0,0 +1,183 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + long page = sysconf(_SC_PAGESIZE); + CHECK(page >= 128 && (size_t)page <= PURE_OBJECT_LIMIT / 3); + size_t size = (size_t)page * 3, offset = (size_t)page + 13, length = (size_t)page + 105; + unsigned char *bytes = malloc(size); + CHECK(bytes); + for (size_t i = 0; i < size; ++i) bytes[i] = (unsigned char)((i * 17u) ^ (i >> 8) ^ (i >> 16)); + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, size}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", offset, length, length, &first) == PURE_OK); + CHECK(first.body.size == length && !memcmp(first.body.data, bytes + offset, length)); + pure_store_release(&first); + free(bytes); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-program.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-program.c new file mode 100644 index 0000000..6a4a19d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-program.c @@ -0,0 +1,111 @@ +#include "program.h" +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static PureProgram parse(const char *source) { + PureProgram program; + char error[160]; + if (!pure_program_parse(source, strlen(source), &program, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return program; +} +static void invalid(const char *source) { + PureProgram program; + memset(&program, 0x5a, sizeof(program)); + unsigned char original[sizeof(program)]; + memcpy(original, &program, sizeof(program)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &program, error, sizeof(error))); + CHECK(*error && !memcmp(original, &program, sizeof(program))); +} +int main(void) { + char source[4096]; + snprintf(source, sizeof(source), "%s %s", get, put); + PureProgram plan = parse(source); + CHECK(plan.read.to_end && plan.write.to_end && !plan.reply_input); + CHECK(!plan.view_definitions && !plan.read_required && !plan.input_required); + snprintf(source, sizeof(source), "%s on put { let a = range(request.body, 2, 10); " + "let b = range(a, 3, 4); let alias = b; " + "let saved = store.write(request.key, request.revision, alias); reply(saved, b); }", get); + plan = parse(source); + CHECK(plan.write.offset == 5 && plan.write.length == 4 && !plan.write.to_end); + CHECK(plan.reply.offset == 5 && plan.reply.length == 4 && plan.reply_input); + CHECK(plan.input_required == 12 && plan.range_definitions == 2 && plan.view_definitions == 3); + /* Ordered reference views keep even unused bounds failures observable. */ + unsigned char bytes[160]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 7); + size_t comparisons = 0; + for (size_t a = 0; a < 4; ++a) for (size_t b = 0; b < 6; ++b) + for (size_t c = 0; c <= b; ++c) for (size_t d = 0; d <= b - c; ++d) { + size_t unused_end = (a + b + c) % 12; + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); " + "let unused = range(o.body, %zu, 0); let first = range(o.body, %zu, %zu); " + "let second = range(first, %zu, %zu); reply(o, second); } %s", + unused_end, a, b, c, d, put); + plan = parse(source); + for (size_t n = 0; n < 16; ++n) { + bool reference_ok = unused_end <= n && a <= n && b <= n - a; + PureSpan selected; + bool prepared_ok = n >= plan.read_required && + pure_region_span(plan.read, (PureSpan){bytes, n}, &selected); + CHECK(reference_ok == prepared_ok); + if (reference_ok) { + CHECK(selected.size == d); + CHECK(!d || !memcmp(selected.data, bytes + a + c, d)); + } + ++comparisons; + } + } + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); reply(o); } %s", put); + plan = parse(source); + CHECK(!plan.read.to_end && !plan.read.length); + const char *bad_get[] = { + "on get { let o = store.read(request.key); let x = range(o.body, 0, 2); let y = range(x, 2, 1); reply(o, y); }", + "on get { let o = store.read(request.key); let x = range(o.body, 184467440737095516160, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let x = range(o.body, 1449984, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let o = o.body; reply(o); }", + "on get { let o = store.read(request.key); let x = x; reply(o, x); }", + "on get { let o = store.read(request.key); let x = store.read(request.key); reply(x); }", + "on get { let o = store.read(request.key); reply(o, request.body); }", + "on get { let o = store.read(request.key); let x = o.body; reply(x); }", + "on get { let o = store.read(request.key); reply(o.body); }", + "on get { let o = store.read(request.key); reply(o); let x = o.body; }", + "on get { let o = store.read(request.key); reply(o);", "on get {}" + }; + for (size_t i = 0; i < sizeof(bad_get) / sizeof(*bad_get); ++i) { + snprintf(source, sizeof(source), "%s %s", bad_get[i], put); invalid(source); + } + const char *bad_put[] = { + "on put { let s = store.write(request.key, request.revision, request.body); let b = range(request.body, 0, 1); reply(s, b); }", + "on put { let s = store.write(request.key, request.revision, request.body); reply(s, s.body); }", + "on put { let s = store.read(request.key); reply(s); }" + }; + for (size_t i = 0; i < sizeof(bad_put) / sizeof(*bad_put); ++i) { + snprintf(source, sizeof(source), "%s %s", get, bad_put[i]); invalid(source); + } + invalid(get); + snprintf(source, sizeof(source), "%s %s %s", get, get, put); invalid(source); + snprintf(source, sizeof(source), "%s %s garbage", get, put); invalid(source); + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); "); + for (size_t i = 0; i < 32; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let x%zu = o.body; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "reply(o); } %s", put); invalid(source); + char oversized[PURE_PROGRAM_LIMIT + 1]; + memset(oversized, ' ', sizeof(oversized)); + char error[160]; + CHECK(!pure_program_parse(oversized, sizeof(oversized), &plan, error, sizeof(error))); + const char embedded[] = "on get\0{}"; + CHECK(!pure_program_parse(embedded, sizeof(embedded) - 1, &plan, error, sizeof(error))); + PureSpan selected; + CHECK(pure_region_span((PureRegion){0}, (PureSpan){0}, &selected) && !selected.data && !selected.size); + printf("{\"program\":\"prepare-views-preserve-guards-types-effects-and-atomic-install\"," + "\"boundary_semantic_comparisons\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-runtime.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-runtime.c new file mode 100644 index 0000000..0ff6a8b --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-runtime.c @@ -0,0 +1,285 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include + +static const PureProgram identity = { + .read = {.to_end = true}, .write = {.to_end = true}, + .reply = {.to_end = true}, .reply_input = true +}; +typedef struct { + size_t allocations, writes, frees, live; + bool allocation_failure, oversized_read, reenter; + PureRuntime *runtime; + unsigned char received[256]; + size_t received_size; +} Host; +typedef struct { Host *host; unsigned char bytes[]; } Allocation; + +static void free_input(void *pointer, size_t size) { + Allocation *a = pointer; + assert(size > 0 && a->host->live > 0); + ++a->host->frees; + --a->host->live; + free(a); +} + +static bool allocate(void *context, size_t size, PureResource *out) { + Host *host = context; + if (host->allocation_failure) return false; + Allocation *a = malloc(sizeof(*a) + size); + assert(a); + a->host = host; + ++host->allocations; + ++host->live; + *out = (PureResource){.body = {a->bytes, size}, .allocation = a, + .allocation_size = size, .release = free_input}; + return true; +} + +static PureResult write_value(void *context, const char *key, int64_t expected, + PureSpan body, int64_t *revision) { + Host *host = context; + (void)key; + if (host->reenter) { + PureTicket ignored; + assert(pure_runtime_accept(host->runtime, PURE_INPUT_WRITE, "reenter", 0, 0, &ignored) == PURE_INVALID); + assert(!pure_runtime_step(host->runtime)); + } + if (expected == 99) { *revision = 1; return PURE_CONFLICT; } + ++host->writes; + assert(body.size <= sizeof(host->received)); + if (body.size) memcpy(host->received, body.data, body.size); + host->received_size = body.size; + *revision = 1; + return PURE_OK; +} + +static PureResult read_value(void *context, const char *key, PureRegion range, + size_t minimum, size_t available, PureResource *out, int64_t *revision) { + Host *host = context; + (void)key; (void)range; (void)minimum; + if (!host->oversized_read) return PURE_NOT_FOUND; + assert(available < 1000); + assert(allocate(host, available + 1, out)); + *revision = 1; + return PURE_OK; /* Deliberately violates the available-credit contract. */ +} + +static PureHost port(Host *host) { + return (PureHost){.context = host, .allocate = allocate, .read = read_value, .write = write_value}; +} + +static PureTicket submit(PureRuntime *r, const char *key, const void *bytes, size_t length) { + PureTicket ticket; + assert(pure_runtime_accept(r, PURE_INPUT_WRITE, key, 0, length, &ticket) == PURE_OK); + size_t capacity; + unsigned char *input = pure_runtime_ingress(ticket, &capacity); + assert(capacity == length); + if (length) memcpy(input, bytes, length); + assert(pure_runtime_ready(ticket)); + assert(!pure_runtime_ready(ticket)); + return ticket; +} + +static PureOutcome completed(PureRuntime *r, PureTicket ticket) { + PureOutcome result; + while (!pure_runtime_result(ticket, &result)) assert(pure_runtime_step(r)); + return result; +} + +static void admission_and_lifecycle(void) { + Host host = {0}; + PureRuntime r; + PureWork work[3]; + assert(pure_runtime_init(&r, work, 3, &identity, port(&host), 5)); + host.runtime = &r; + PureTicket a, untouched = {0}, out = untouched; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "../bad", 0, 1, &out) == PURE_INVALID); + assert(!out.runtime && !host.allocations); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "a", 0, 4, &a) == PURE_OK); + assert(r.retained == 4 && !pure_runtime_step(&r) && !host.writes); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "b", 0, 2, &out) == PURE_BUDGET); + assert(!out.runtime && host.allocations == 1); + assert(pure_runtime_cancel(a)); + PureOutcome cancelled; + assert(pure_runtime_result(a, &cancelled) && cancelled.status == PURE_CANCELLED); + assert(r.retained == 0 && host.frees == 1 && !pure_runtime_cancel(a)); + assert(pure_runtime_release(a) && !pure_runtime_release(a)); + assert(!pure_runtime_result(a, &cancelled)); + + PureTicket first = submit(&r, "first", "1", 1); + PureTicket middle = submit(&r, "middle", "2", 1); + PureTicket last = submit(&r, "last", "3", 1); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "full", 0, 0, &out) == PURE_BUSY); + assert(pure_runtime_cancel(middle)); + assert(pure_runtime_release(middle)); + pure_runtime_stop(&r); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "closed", 0, 0, &out) == PURE_CLOSED); + assert(!pure_runtime_restart(&r, &identity, port(&host), 5)); + host.reenter = true; + assert(completed(&r, first).status == PURE_OK && host.received[0] == '1'); + assert(completed(&r, last).status == PURE_OK && host.received[0] == '3'); + assert(!pure_runtime_step(&r)); + assert(pure_runtime_release(first) && pure_runtime_release(last)); + assert(r.active == 0 && r.retained == 0 && host.live == 0); + assert(pure_runtime_restart(&r, &identity, port(&host), 5)); + PureTicket next = submit(&r, "next", "4", 1); + assert(next.generation > last.generation && !pure_runtime_ready(a)); + assert(!pure_runtime_release(last)); + assert(pure_runtime_release(next)); /* queued input: no effect */ + assert(host.writes == 2 && !pure_runtime_step(&r)); + + host.allocation_failure = true; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "no_memory", 0, 1, &out) == PURE_ALLOCATION); + assert(r.active == 0 && r.retained == 0); + host.allocation_failure = false; + PureHost denied = port(&host); + denied.write = NULL; + assert(pure_runtime_restart(&r, &identity, denied, 5)); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "denied", 0, 1, &out) == PURE_DENIED); + assert(pure_runtime_restart(&r, &identity, port(&host), 5)); + host.oversized_read = true; + assert(pure_runtime_accept(&r, PURE_INPUT_READ, "bad_host", 0, 0, &out) == PURE_OK); + assert(completed(&r, out).status == PURE_BUDGET && host.live == 0 && r.retained == 0); + assert(pure_runtime_release(out)); + r.serial = UINT64_MAX; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "exhausted", 0, 0, &out) == PURE_LIMIT); +} + +static void forwarding(void) { + Host a = {0}, b = {0}, c = {0}; + PureRuntime source, left, right; + PureWork source_work[1], left_work[1], right_work[1]; + assert(pure_runtime_init(&source, source_work, 1, &identity, port(&a), 8)); + assert(pure_runtime_init(&left, left_work, 1, &identity, port(&b), 2)); + assert(pure_runtime_init(&right, right_work, 1, &identity, port(&c), 8)); + PureTicket original = submit(&source, "original", "value", 5), x = {0}, y; + PureOutcome result = completed(&source, original); + assert(result.status == PURE_OK); + const unsigned char *backing = result.body.data; + assert(pure_runtime_forward(original, &left, "left", 0, &x) == PURE_BUDGET); + assert(!x.runtime && source.work[0].pins == 0); + assert(pure_runtime_restart(&left, &identity, port(&b), 8)); + assert(pure_runtime_forward(original, &left, "left", 0, &x) == PURE_OK); + assert(pure_runtime_forward(original, &right, "right", 0, &y) == PURE_OK); + assert(pure_runtime_release(original)); + assert(!pure_runtime_result(original, &result)); + pure_runtime_stop(&source); + assert(!pure_runtime_restart(&source, &identity, port(&a), 8)); + assert(a.live == 1 && !b.allocations && !c.allocations); + assert(pure_runtime_cancel(x) && pure_runtime_release(x)); + assert(a.live == 1 && !b.writes); + result = completed(&right, y); + assert(result.body.data == backing && result.body.size == 5 && !memcmp(result.body.data, "value", 5)); + assert(c.writes == 1 && !c.allocations && source.retained == 5); + assert(!pure_runtime_cancel(y)); /* effects cannot be rolled back */ + assert(pure_runtime_release(y)); + assert(a.frees == 1 && !source.active && !source.retained && !right.active && !right.retained); + assert(pure_runtime_restart(&source, &identity, port(&a), 8)); + + /* A completed consumer does not retain its input when the program has no + * input-backed reply. The producing domain can then be reclaimed early. */ + PureProgram no_reply = identity; + no_reply.reply_input = false; + assert(pure_runtime_restart(&right, &no_reply, port(&c), 8)); + original = submit(&source, "again", "last", 4); + assert(completed(&source, original).status == PURE_OK); + assert(pure_runtime_forward(original, &right, "no_reply", 0, &y) == PURE_OK); + assert(pure_runtime_release(original)); + assert(completed(&right, y).body.size == 0 && a.live == 0); + assert(source.active == 0 && right.active == 1 && right.retained == 0); + assert(pure_runtime_release(y)); +} + +static void long_chain(void) { + enum { COUNT = 1024 }; + PureWork *work = calloc(COUNT, sizeof(*work)); + assert(work); + Host host = {0}; + PureRuntime r; + assert(pure_runtime_init(&r, work, COUNT, &identity, port(&host), COUNT)); + PureTicket ticket = submit(&r, "chain", "x", 1); + for (size_t i = 1; i < COUNT; ++i) { + assert(completed(&r, ticket).status == PURE_OK); + PureTicket next; + assert(pure_runtime_forward(ticket, &r, "chain", 0, &next) == PURE_OK); + assert(pure_runtime_release(ticket)); + ticket = next; + } + assert(completed(&r, ticket).status == PURE_OK); + assert(host.allocations == 1 && r.active == COUNT); + assert(pure_runtime_release(ticket)); + assert(r.retained == 0 && r.active == 0 && host.frees == 1); + free(work); +} + +static void actual_storage(void) { + char directory[] = "/tmp/pure-runtime-check-XXXXXX"; + assert(mkdtemp(directory)); + char source_path[128], target_path[128], name[160]; + snprintf(source_path, sizeof(source_path), "%s/source", directory); + snprintf(target_path, sizeof(target_path), "%s/target", directory); + assert(pure_store_init(source_path) == PURE_OK && pure_store_init(target_path) == PURE_OK); + PureProgram selection; + char error[160]; + assert(pure_program_load("examples/selection.pure", &selection, error, sizeof(error))); + PureRuntime source, target; + PureWork a[2], b[2]; + assert(pure_runtime_init(&source, a, 2, &selection, pure_store_host(source_path), 128)); + assert(pure_runtime_init(&target, b, 2, &identity, pure_store_host(target_path), 128)); + const unsigned char bytes[] = {1,0,0,0, 3,0,0,0, 'a','b','c','d','e'}; + PureTicket ticket = submit(&source, "excerpt", bytes, sizeof(bytes)), consumer; + PureOutcome result = completed(&source, ticket); + assert(result.status == PURE_OK && result.body.size == 3 && !memcmp(result.body.data, "bcd", 3)); + assert(pure_runtime_forward(ticket, &target, "archive", 0, &consumer) == PURE_OK); + assert(pure_runtime_release(ticket)); + assert(source.active == 1 && source.retained == sizeof(bytes)); + assert(completed(&target, consumer).status == PURE_OK); + assert(pure_runtime_release(consumer) && source.active == 0); + PureStoredView stored = {0}; + assert(pure_store_get(target_path, "archive", 128, &stored) == PURE_OK); + assert(stored.body.size == 3 && !memcmp(stored.body.data, "bcd", 3)); + pure_store_release(&stored); + + assert(pure_runtime_accept(&source, PURE_INPUT_READ, "excerpt", 0, 0, &ticket) == PURE_OK); + assert(completed(&source, ticket).status == PURE_OK); + assert(pure_runtime_forward(ticket, &target, "snapshot", 0, &consumer) == PURE_OK); + assert(pure_runtime_release(ticket)); + int64_t revision; + assert(pure_store_put(source_path, "excerpt", 1, (PureSpan){(const unsigned char *)"new", 3}, &revision) == PURE_OK); + result = completed(&target, consumer); + assert(result.body.size == 3 && !memcmp(result.body.data, "bcd", 3)); + assert(pure_runtime_release(consumer)); + + unsigned char invalid[] = {255,255,255,255, 1,0,0,0, 'x'}; + ticket = submit(&source, "invalid", invalid, sizeof(invalid)); + assert(completed(&source, ticket).status == PURE_LIMIT); + assert(pure_runtime_release(ticket)); + assert(pure_store_get(source_path, "invalid", 128, &stored) == PURE_NOT_FOUND); + assert(source.active == 0 && target.active == 0 && source.retained == 0 && target.retained == 0); + const char *files[] = {"source/obj-excerpt", "source/.writer.lock", "target/obj-archive", + "target/obj-snapshot", "target/.writer.lock"}; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) { + snprintf(name, sizeof(name), "%s/%s", directory, files[i]); + assert(unlink(name) == 0); + } + assert(rmdir(source_path) == 0 && rmdir(target_path) == 0 && rmdir(directory) == 0); +} + +int main(void) { + admission_and_lifecycle(); + forwarding(); + long_chain(); + actual_storage(); + printf("{\"runtime_tests\":\"passed\",\"domain_bytes\":%zu,\"work_slot_bytes\":%zu," + "\"ticket_bytes\":%zu,\"prepared_program_bytes\":%zu,\"forwarding_chain\":1024," + "\"native_storage_pipeline\":true}\n", sizeof(PureRuntime), sizeof(PureWork), + sizeof(PureTicket), sizeof(PureProgram)); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-splice.c b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-splice.c new file mode 100644 index 0000000..5b8232b --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/connections/test-splice.c @@ -0,0 +1,172 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "line %d: %s\n", __LINE__, #x); exit(1); } } while (0) +static int failed_stage = -1; +int pure_store_test_fault(PureStoreStage stage) { return (int)stage == failed_stage; } + +static void expect(const char *path, const char *key, int64_t revision, PureSpan bytes) { + PureStoredView view; + CHECK(pure_store_get(path, key, PURE_OBJECT_LIMIT, &view) == PURE_OK); + CHECK(view.revision == revision && view.body.size == bytes.size); + CHECK(!bytes.size || !memcmp(view.body.data, bytes.data, bytes.size)); + pure_store_release(&view); +} + +static void parser(void) { + const char *get = "on get { let o = store.read(request.key); reply(o, o.body); }"; + const char *layout = "layout E { marker: bytes[1]; remove: u32le; start: u32le; insert: bytes; }"; + char source[2048], error[160]; + PureProgram p; + snprintf(source, sizeof(source), "%s %s on put { let e = view(E, request.body); " + "let s = store.splice(request.key, request.revision, e.start, e.remove, e.insert); reply(s, e.insert); }", layout, get); + CHECK(pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(p.splice && p.input_required == 9 && p.splice_offset.value == 5 && p.splice_remove.value == 1); + unsigned char input[] = {0, 2,0,0,0, 3,0,0,0, 0,255}; + PureSpan write, reply; + size_t offset, remove; + for (size_t n = 0; n < 9; ++n) + CHECK(!pure_program_input(&p, (PureSpan){input, n}, &write, &reply)); + CHECK(pure_program_input(&p, (PureSpan){input, sizeof(input)}, &write, &reply)); + CHECK(write.size == 2 && write.data == input + 9 && reply.data == write.data); + CHECK(pure_program_splice(&p, (PureSpan){input, sizeof(input)}, &offset, &remove)); + CHECK(offset == 3 && remove == 2); + + snprintf(source, sizeof(source), "%s on put { let s = store.splice(request.key, request.revision, 0, 0, request.body); reply(s); }", get); + CHECK(pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(pure_program_splice(&p, (PureSpan){0}, &offset, &remove) && offset == 0 && remove == 0); + const char *invalid[] = { + "let s = store.splice(request.key, request.revision, e.insert, 0, e.insert); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 1449985, e.insert); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 0, e.start); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 0, e.insert); let t = store.write(request.key, request.revision, e.insert); reply(t);" + }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(*invalid); ++i) { + snprintf(source, sizeof(source), "%s %s on put { let e = view(E, request.body); %s }", layout, get, invalid[i]); + unsigned char before[sizeof(p)]; + memcpy(before, &p, sizeof(p)); + CHECK(!pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(*error && !memcmp(before, &p, sizeof(p))); + } +} + +static void storage(const char *path) { + /* Reference whole-value assembly exists only in this test oracle. */ + unsigned char oracle[65536], before[65536], insert[9000]; + for (size_t i = 0; i < sizeof(insert); ++i) insert[i] = (unsigned char)(i * 19u); + size_t length = 49157; + for (size_t i = 0; i < length; ++i) oracle[i] = (unsigned char)((i * 17u) ^ (i >> 8)); + int64_t revision; + CHECK(pure_store_splice(path, "work", 0, 0, 0, (PureSpan){oracle, length}, &revision) == PURE_OK); + CHECK(revision == 1); + /* Prefix/suffix copies cross both scratch and mapping page boundaries. */ + const size_t edits[][3] = {{16381, 7, 9000}, {0, 0, 5}, {50000, 1, 0}, {4095, 4097, 3}, {0, 0, 0}}; + for (size_t i = 0; i < sizeof(edits) / sizeof(*edits); ++i) { + size_t offset = edits[i][0], remove = edits[i][1], count = edits[i][2], previous = length; + memcpy(before, oracle, length); + PureStoredView old; + CHECK(pure_store_get(path, "work", PURE_OBJECT_LIMIT, &old) == PURE_OK); + int64_t expected = revision; + CHECK(pure_store_splice(path, "work", expected, offset, remove, (PureSpan){insert, count}, &revision) == PURE_OK); + CHECK(revision == expected + 1); + memmove(oracle + offset + count, oracle + offset + remove, length - offset - remove); + memcpy(oracle + offset, insert, count); + length = length - remove + count; + expect(path, "work", revision, (PureSpan){oracle, length}); + CHECK(old.body.size == previous && !memcmp(old.body.data, before, previous)); + pure_store_release(&old); + } + int64_t latest = revision; + CHECK(pure_store_splice(path, "work", latest - 1, 0, 0, (PureSpan){insert, 1}, &revision) == PURE_CONFLICT); + CHECK(revision == latest); + CHECK(pure_store_splice(path, "work", latest, SIZE_MAX, 0, (PureSpan){0}, &revision) == PURE_LIMIT); + CHECK(pure_store_splice(path, "work", latest, 0, SIZE_MAX, (PureSpan){0}, &revision) == PURE_LIMIT); + CHECK(pure_store_splice(path, "work", latest, length + 1, 0, (PureSpan){0}, &revision) == PURE_LIMIT); + /* Rejected before reading the intentionally undersized insertion pointer. */ + CHECK(pure_store_splice(path, "work", latest, 0, 0, (PureSpan){insert, PURE_OBJECT_LIMIT}, &revision) == PURE_LIMIT); + expect(path, "work", latest, (PureSpan){oracle, length}); + CHECK(pure_store_splice(path, "missing", 0, 1, 0, (PureSpan){0}, &revision) == PURE_LIMIT); + PureStoredView view; + CHECK(pure_store_get(path, "missing", 0, &view) == PURE_NOT_FOUND); + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_splice(path, "work", latest, 1, 3, (PureSpan){insert, 5}, &revision) == PURE_STORE_ERROR); + expect(path, "work", latest, (PureSpan){oracle, length}); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_splice(path, "work", latest, length, 0, (PureSpan){insert, 5}, &revision) == PURE_UNCERTAIN); + CHECK(revision == latest + 1); + failed_stage = -1; + memcpy(oracle + length, insert, 5); length += 5; + expect(path, "work", revision, (PureSpan){oracle, length}); + CHECK(pure_store_splice(path, "work", revision, 0, length, (PureSpan){0}, &revision) == PURE_OK); + expect(path, "work", revision, (PureSpan){0}); + char file[256]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(fd >= 0 && pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_splice(path, "work", INT64_MAX, 0, 0, (PureSpan){0}, &revision) == PURE_LIMIT); +} + +static void runtime(const char *path) { + PureProgram program; + char error[160]; + CHECK(pure_program_load("examples/editor.pure", &program, error, sizeof(error))); + PureRuntime runtime; + PureWork slots[1]; + PureHost denied = pure_store_host(path); + denied.splice = NULL; /* ordinary writes do not grant permission to splice */ + CHECK(pure_runtime_init(&runtime, slots, 1, &program, denied, 32)); + PureTicket ticket = {0}; + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 9, &ticket) == PURE_DENIED); + CHECK(!ticket.runtime && runtime.active == 0 && runtime.retained == 0); + CHECK(pure_runtime_restart(&runtime, &program, pure_store_host(path), 32)); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 9, &ticket) == PURE_OK); + CHECK(pure_runtime_cancel(ticket) && pure_runtime_release(ticket)); + CHECK(!pure_runtime_step(&runtime)); + PureStoredView view; + CHECK(pure_store_get(path, "native", 32, &view) == PURE_NOT_FOUND); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 7, &ticket) == PURE_OK); + memset(pure_runtime_ingress(ticket, NULL), 0, 7); + CHECK(pure_runtime_ready(ticket) && pure_runtime_step(&runtime)); + PureOutcome result; + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_LIMIT); + CHECK(pure_runtime_release(ticket) && runtime.commits == 0); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 9, &ticket) == PURE_OK); + unsigned char *input = pure_runtime_ingress(ticket, NULL); + memset(input, 0, 8); input[8] = 'x'; + CHECK(pure_runtime_ready(ticket) && pure_runtime_step(&runtime)); + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_OK && result.revision == 1); + CHECK(result.body.size == 1 && result.body.data == input + 8 && runtime.retained == 9); + expect(path, "native", 1, (PureSpan){(const unsigned char *)"x", 1}); + CHECK(pure_runtime_release(ticket) && runtime.retained == 0 && runtime.active == 0); +} + +int main(void) { + parser(); + char path[] = "/tmp/pure-splice-check-XXXXXX"; + CHECK(mkdtemp(path) && pure_store_init(path) == PURE_OK); + storage(path); + runtime(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(strncmp(entry->d_name, ".pending-", 9)); + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + puts("{\"splice_tests\":\"passed\",\"checks\":[\"authored-fields-and-atomic-parse-failure\"," + "\"binary-edit-oracle-and-old-snapshot\",\"bounds-conflict-size-overflow\"," + "\"publication-failures-and-uncertain-durability\",\"cancel-admission-and-borrowed-reply\"]}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/core.wasm new file mode 100644 index 0000000..79c50a8 Binary files /dev/null and b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-document-edits/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/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/connections/results/2026-09-23-document-edits/verification.json b/prototypes/connections/results/2026-09-23-document-edits/verification.json new file mode 100644 index 0000000..598aed9 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-document-edits/verification.json @@ -0,0 +1,32 @@ +{ + "sources_verified_and_frozen": 37, + "native_and_sanitized_source_hashes_match": true, + "recorded_binaries_verified": 8, + "integration_scenarios_per_build": 36, + "server_runs_per_build": 19, + "all_server_runs_drained": true, + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "core_sections": { + "text": 5932, + "data": 0, + "bss": 0 + }, + "structure_sizes": { + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8 + }, + "core_external_symbols": [ + "memcpy", + "strlen" + ], + "server_structure_bytes": [41528], + "limitations": [ + "synchronous full-file rewrite with 4096-byte host scratch", + "11-byte credit test does not measure total memory", + "limited storage authoring profile, no native GUI or secure peer RPC", + "GNU footprint tooling validated on Linux AArch64; macOS unverified" + ] +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/README.md b/prototypes/connections/results/2026-09-23-execution-core/README.md new file mode 100644 index 0000000..fd92adb --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/README.md @@ -0,0 +1,28 @@ +# 실행 코어 검증 기록 + +2026-09-23, Linux AArch64, GCC 14.2.0. 첫 공개 체크포인트 진행 중의 증거다. + +- `native.json`: 일반 빌드의 실제 TCP 28개 시나리오, 실행 코어의 수명·예산·세대·내부 전달·실제 저장 검사. +- `sanitized.json`: 같은 경로를 ASan·UBSan 및 기본 누수 검사로 통과한 결과. +- `verification.json`: 두 결과의 소스 일치, 바이너리 해시, 종료 시 자원 회수, 코어 외부 심볼과 크기 기록. +- `sources/`: 결과에 기록된 30개 소스·클라이언트 파일의 해시 검증 후 복사본. + +당시 실행 명령은 `prototypes/connections`에서 다음과 같다. + +```sh +make all +make check EVIDENCE=results/2026-09-23-execution-core/native.json +make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-execution-core/sanitized.json +size build/runtime.o build/evaluate.o build/pure-connections +nm -u build/libpure-execution.a +``` + +기존 증거 파일은 덮어쓰지 않는다. 재현할 때는 `EVIDENCE`를 생략하거나 새 경로를 사용한다. +동결된 소스는 `sources/prototypes/connections`에서 `make all`과 `make check`로 빌드·검사할 수 있다. +검사에는 C17 컴파일러·make·Python·Node와 로컬 소켓/자식 프로세스 실행이 필요하다. + +영역 192 B, 작업 슬롯 224 B, 티켓 24 B는 구조체 크기다. 코어 오브젝트의 `size text` 합 +5,672 B는 전체 RSS가 아니다. 준비 프로그램·작업 슬롯·실제 본문·호스트 자원 등은 별도이며, +서버 구조체는 기능 추가로 이전보다 1,664 B 증가했다. 처리량·지연의 우위를 측정한 자료가 아니다. +동기 효과·단일 소유 스레드·get/put 작성 프로필이라는 현재 한계는 +[022 보고서](../../../../docs/research/022-execution-core.md)에 기록했다. diff --git a/prototypes/connections/results/2026-09-23-execution-core/native.json b/prototypes/connections/results/2026-09-23-execution-core/native.json new file mode 100644 index 0000000..173e833 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/native.json @@ -0,0 +1,505 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ] + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ] + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11679, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 8, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41488 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 192, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 648, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "f0700e597f634c2304ccb4cbc375d8b491964ed8be6e2aa573a58df9ee00c5fe", + "binary_sha256": "09ecc629b9bbddf09f5b6090c9b67eec7d2e70c6065624c60b902b5b3f8ae802", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b7e7d6806cf9785df1d01f2418d1e493eaf2d4b9cf43abc90082c60bd61dcd0b", + "prototypes/connections/store.h": "d2d7f8bd9845ce13095520f4401b9c48b1b4e8b6da10f8c4c63ab495c2950ef5", + "prototypes/connections/program.c": "b72b0aef7fc04ef044e3c0d177222aea688765526316fe2d0de988573ce8003b", + "prototypes/connections/program.h": "2c3c955967883a9a8ea4566a006eccd44f3a173ac0682c2f272d09700f209639", + "prototypes/connections/runtime.c": "d1163fdc6e5a1948ec0109fc1d04312a0b71edebf8f3762f38ac674ccd686af3", + "prototypes/connections/runtime.h": "03a69db466a217fff195183af174acba1949772c16523aab6f425e84600798e5", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "d3fbbd4ddba2b7fe29fc11ba46d3fc471945f724afff52bb5d7699f79cb80b5d", + "prototypes/connections/store-port.c": "5c7c3c1dcd2ae90a39ecc93b5bccf7f54fcafe1d0e10b2ad7b1eb0c89c1382bf", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "d1e2dc14185a8b4301cc6bfa001ee2cdfa674acad505b92c51f06311abf36be9", + "prototypes/connections/server.c": "21dc7edf1acb58f347586a92bab02f2d7edf6a7369c9b8212acab8b87f58aa74", + "prototypes/connections/check.py": "6bf6ccb8acd14a01f635733d3a9a42439a6f9dc37a9d43b5e3681301a9ab2235", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "1c4083e549bf2bb83e0127c910607e60cefd6f3af9e1fae355caadb2506ded09", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sanitized.json b/prototypes/connections/results/2026-09-23-execution-core/sanitized.json new file mode 100644 index 0000000..67c195e --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sanitized.json @@ -0,0 +1,505 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ] + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ] + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 14232, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41488 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41488 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 192, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 648, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "eb526e3dff8a87f1f53446efa25026a9720058bf414be08ce8cc57a9787c6bb8", + "binary_sha256": "49fb5aec3b1ec71cea66ab708e507583cc2403203845e8737a8c0fb2a050a899", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b7e7d6806cf9785df1d01f2418d1e493eaf2d4b9cf43abc90082c60bd61dcd0b", + "prototypes/connections/store.h": "d2d7f8bd9845ce13095520f4401b9c48b1b4e8b6da10f8c4c63ab495c2950ef5", + "prototypes/connections/program.c": "b72b0aef7fc04ef044e3c0d177222aea688765526316fe2d0de988573ce8003b", + "prototypes/connections/program.h": "2c3c955967883a9a8ea4566a006eccd44f3a173ac0682c2f272d09700f209639", + "prototypes/connections/runtime.c": "d1163fdc6e5a1948ec0109fc1d04312a0b71edebf8f3762f38ac674ccd686af3", + "prototypes/connections/runtime.h": "03a69db466a217fff195183af174acba1949772c16523aab6f425e84600798e5", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "d3fbbd4ddba2b7fe29fc11ba46d3fc471945f724afff52bb5d7699f79cb80b5d", + "prototypes/connections/store-port.c": "5c7c3c1dcd2ae90a39ecc93b5bccf7f54fcafe1d0e10b2ad7b1eb0c89c1382bf", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "d1e2dc14185a8b4301cc6bfa001ee2cdfa674acad505b92c51f06311abf36be9", + "prototypes/connections/server.c": "21dc7edf1acb58f347586a92bab02f2d7edf6a7369c9b8212acab8b87f58aa74", + "prototypes/connections/check.py": "6bf6ccb8acd14a01f635733d3a9a42439a6f9dc37a9d43b5e3681301a9ab2235", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "1c4083e549bf2bb83e0127c910607e60cefd6f3af9e1fae355caadb2506ded09", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..ea15285 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/Makefile @@ -0,0 +1,43 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build + +.PHONY: all check +CORE = runtime.c evaluate.c +HOST = store.c store-port.c +HEADERS = runtime.h program.h store.h store-port.h flow.h result.h +all: $(BUILD)/pure-connections $(BUILD)/libpure-execution.a + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c $(HOST) flow.c program.c $(CORE) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c $(HOST) flow.c program.c $(CORE) $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/runtime.o: runtime.c $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c runtime.c -o $@ + +$(BUILD)/evaluate.o: evaluate.c program.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -c evaluate.c -o $@ + +$(BUILD)/libpure-execution.a: $(BUILD)/runtime.o $(BUILD)/evaluate.o + $(AR) rcs $@ $^ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-program: test-program.c program.c evaluate.c program.h store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-program.c program.c evaluate.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-fields: test-fields.c program.c evaluate.c program.h store.h flow.h result.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-fields.c program.c evaluate.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-runtime: test-runtime.c program.c $(CORE) $(HOST) $(HEADERS) | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-runtime.c program.c $(CORE) $(HOST) $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/test-flow $(BUILD)/test-program $(BUILD)/test-fields $(BUILD)/test-runtime $(BUILD)/pure-connections + $(BUILD)/test-flow + $(BUILD)/test-program + $(BUILD)/test-fields + python3 check.py --server $(BUILD)/pure-connections --runtime-test $(BUILD)/test-runtime $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/check.py new file mode 100644 index 0000000..a512748 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/check.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +"""Exercise local TCP, immutable byte extents, and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import fcntl +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import struct +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, storage, budget=4 * 1024 * 1024, timeout=5000, program=None): + self.process = subprocess.Popen([str(binary), '--store', str(storage), '--port', '0', + '--program', str(program or ROOT / 'examples/storage.pure'), + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory): + storage = directory / 'objects' + checks, records = [], [] + with serving(binary, storage) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + with (storage / '.writer.lock').open('r+b') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + fcntl.lockf(lock, fcntl.LOCK_UN) + broken = storage / 'obj-broken' + broken.write_bytes(b'incomplete') + assert rpc(server, 'GET', 'broken')[0] == 500 + assert rpc(server, 'PUT', 'broken', b'failure', 0)[0] == 500 + assert broken.read_bytes() == b'incomplete' + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('writer contention and invalid extent fail without overwriting data') + + with serving(binary, storage) as second_writer: + with ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(rpc, target, 'PUT', 'race', value, 0) + for target, value in [(server, b'first'), (second_writer, b'second')]] + statuses = [task.result()[0] for task in tasks] + assert statuses.count(200) == 1 and all(status in [200, 412, 503] for status in statuses) + status, headers, body = rpc(server, 'GET', 'race') + assert status == 200 and headers['ETag'] == '"1"' and body in [b'first', b'second'] + records.append(second_writer.stats) + checks.append('two native writers cannot both create the same expected revision') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the immutable file whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock storage. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + wide = storage / 'obj-wide_revision' + raw = wide.read_bytes() + wide.write_bytes(raw[:8] + struct.pack('= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + prepared = authored_connections(binary, directory, storage, records, checks) + fields = field_connections(binary, directory, storage, records, checks) + return {'kind': 'prepared-connection-correctness-not-performance', 'checks': checks, + 'prepared_connections': prepared, + 'field_connections': fields, + 'server_runs': records, 'storage': server.ready['storage']} + + +def authored_connections(binary, directory, storage, records, checks): + get = 'on get { let o = store.read(request.key); reply(o, o.body); }' + put = 'on put { let s = store.write(request.key, request.revision, request.body); reply(s); }' + + def author(name, source): + path = directory / name + path.write_text(source) + return path + + def explain(path): + return json.loads(subprocess.check_output([str(binary), '--check-program', str(path)], text=True)) + + with serving(binary, storage) as writer: + assert rpc(writer, 'PUT', 'short_parent', bytes(range(48)), 0)[0] == 200 + assert rpc(writer, 'PUT', 'short_unused', bytes(range(80)), 0)[0] == 200 + records.append(writer.stats) + excerpt = ROOT / 'examples/excerpt.pure' + prepared = {'excerpt': explain(excerpt)} + assert prepared['excerpt']['read'] == { + 'to_end': False, 'offset': 32, 'length': 16, 'minimum_source_bytes': 80} + with serving(binary, storage, budget=16, program=excerpt) as selected: + status, headers, body = rpc(selected, 'GET', 'large') + assert status == 200 and body == bytes(range(32, 48)) and headers['ETag'] == '"3"' + # Final [32,48) fits, but the authored parent [16,80) does not. + assert rpc(selected, 'GET', 'short_parent')[0] == 413 + assert rpc(selected, 'GET', 'missing')[0] == 404 + assert selected.stats['peak_retained_payload_bytes'] == 16 + records.append(selected.stats) + checks.append('authored nested read maps only final demand while preserving parent bounds and revision') + + unused = author('unused.pure', 'on get { let o = store.read(request.key); ' + 'let unused = range(o.body, 0, 96); let body = range(o.body, 32, 16); reply(o, body); } ' + put) + with serving(binary, storage, budget=16, program=unused) as guarded: + assert rpc(guarded, 'GET', 'short_unused')[0] == 413 + assert rpc(guarded, 'GET', 'large')[2] == bytes(range(32, 48)) + records.append(guarded.stats) + checks.append('unused checked view still fails even when response range would be valid') + + select_write = author('write.pure', get + ' on put { let a = range(request.body, 2, 10); ' + 'let b = range(a, 3, 4); let alias = b; ' + 'let saved = store.write(request.key, request.revision, alias); reply(saved, b); }') + prepared['write'] = explain(select_write) + with serving(binary, storage, program=select_write) as writer: + status, headers, body = rpc(writer, 'PUT', 'selected', b'0123456789AB', 0) + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'GET', 'selected')[2] == b'5678' + assert rpc(writer, 'PUT', 'selected', b'012345678', 1)[0] == 413 + status, headers, body = rpc(writer, 'GET', 'selected') + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'PUT', 'selected', b'0123456789AB', 0)[0] == 412 + records.append(writer.stats) + checks.append('authored write and reply share selected input; invalid parent prevents the storage effect') + + metadata = author('metadata.pure', 'on get { let o = store.read(request.key); reply(o); } ' + put) + with serving(binary, storage, budget=1, program=metadata) as meta: + status, headers, body = rpc(meta, 'GET', 'large') + assert status == 200 and headers['ETag'] == '"3"' and body == b'' + assert rpc(meta, 'GET', 'absent')[0] == 404 + assert rpc(meta, 'GET', 'broken')[0] == 500 + assert meta.stats['peak_retained_payload_bytes'] == 0 + records.append(meta.stats) + checks.append('body-free response preserves read existence and malformed-object errors') + + echo = author('echo.pure', get + ' on put { let body = request.body; ' + 'let saved = store.write(request.key, request.revision, body); reply(saved, body); }') + payload = bytes(range(256)) * 4096 + with serving(binary, storage, budget=len(payload), program=echo) as echoed: + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo', len(payload), b'If-None-Match: *\r\n') + payload) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.read(1) == payload[:1] + assert rpc(echoed, 'GET', 'binary')[0] == 503 + assert response.read() == payload[1:] + response.close() + assert rpc(echoed, 'GET', 'binary')[2] == b'new' + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo_abort', len(payload), b'If-None-Match: *\r\n') + payload) + assert sock.recv(1) + assert rpc(echoed, 'GET', 'binary')[0] == 503 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + deadline = time.monotonic() + 3 + while rpc(echoed, 'GET', 'binary')[0] == 503: + assert time.monotonic() < deadline + time.sleep(0.01) + assert rpc(echoed, 'GET', 'echo_abort')[2] == payload + records.append(echoed.stats) + checks.append('input-backed response survives partial send and releases on finish or abort without undoing commit') + + bad = author('invalid.pure', get + get + put) + untouched = directory / 'must_not_exist' + rejected = subprocess.run([str(binary), '--program', str(bad), '--store', str(untouched)], + capture_output=True, text=True, timeout=10) + assert rejected.returncode == 2 and 'duplicate handler' in rejected.stderr and not untouched.exists() + checks.append('invalid authored program fails preparation before creating storage or accepting requests') + return prepared + + +def field_connections(binary, directory, storage, records, checks): + program = ROOT / 'examples/selection.pure' + plans = {'selection': json.loads(subprocess.check_output( + [str(binary), '--check-program', str(program)], text=True))} + assert plans['selection']['runtime_view_steps'] == 1 + assert plans['selection']['steps'][0] == { + 'source_step': 0, 'source': {'offset': 8, 'length': 0, 'to_end': True}, + 'offset': {'value': 0, 'from_u32le': True}, 'length': {'value': 4, 'from_u32le': True}} + document = '처음🙂 Pure는 필드를 연결한다.\n끝' + document_file = directory / 'document.txt' + document_file.write_bytes(document.encode('utf-8')) + with serving(binary, storage, program=program) as selected: + command = [sys.executable, str(ROOT / 'examples/select-text.py'), str(document_file), + '--key', 'text_selection', '--start', '2', '--end', '12', '--url', f'http://127.0.0.1:{selected.port}'] + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, (result.stdout, result.stderr) + expected = document[2:12].encode('utf-8') + assert json.loads(result.stdout) == {'revision': '1', 'selected': document[2:12], 'stored_bytes': len(expected)} + assert rpc(selected, 'GET', 'text_selection')[2] == expected + checks.append('text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes') + + # Validation precedes the write: even a stale header cannot expose or + # commit partial input, and prior content/revision survive all failures. + invalid = [b'\0' * n for n in range(8)] + [ + struct.pack(' + +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; +} + +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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/excerpt.pure b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/excerpt.pure new file mode 100644 index 0000000..622f9bb --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/excerpt.pure @@ -0,0 +1,13 @@ +// Read bytes 32..47 of an object that must contain the complete 64-byte window. +// Preparation keeps the size check for 16..79, and maps only 32..47 for reply. +on get { + let object = store.read(request.key); + let window = range(object.body, 16, 64); + let excerpt = range(window, 16, 16); + reply(object, excerpt); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/select-text.py b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/select-text.py new file mode 100644 index 0000000..ffaf80b --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/select-text.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Save a UTF-8 document selection through selection.pure (character indices).""" +import argparse +import http.client +import json +from pathlib import Path +import re +import struct +from urllib.parse import urlsplit + +LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('file', type=Path) + parser.add_argument('--key', required=True) + parser.add_argument('--start', type=int, required=True, help='first Unicode code point, starting at 0') + parser.add_argument('--end', type=int, required=True, help='exclusive Unicode code point boundary') + parser.add_argument('--revision', type=int, default=0, help='0 creates; otherwise expected stored revision') + parser.add_argument('--url', default='http://127.0.0.1:8767') + args = parser.parse_args() + url = urlsplit(args.url) + try: + port = url.port or 80 + except ValueError as error: + parser.error(str(error)) + if (url.scheme != 'http' or url.hostname not in ['localhost', '127.0.0.1'] or + url.username is not None or url.password is not None or url.path not in ['', '/'] or + url.query or url.fragment): + parser.error('use a local HTTP server origin') + if not re.fullmatch(r'[A-Za-z0-9_-]{1,64}', args.key) or not 0 <= args.revision <= 2**63 - 1: + parser.error('invalid object key or revision') + try: + with args.file.open('rb') as source: + document = source.read(LIMIT - 8 + 1) + if len(document) > LIMIT - 8: + parser.error('document exceeds the prototype request limit') + text = document.decode('utf-8') + except (OSError, UnicodeError) as error: + parser.error(str(error)) + if not 0 <= args.start <= args.end <= len(text): + parser.error('selection is outside the document') + start = len(text[:args.start].encode('utf-8')) + selected = text[args.start:args.end].encode('utf-8') + # The boundary layout is explicit; storage receives only selected bytes. + payload = struct.pack(' 2**63 - 1: + parser.error('response has no valid stored revision (write may have occurred)') + except (OSError, http.client.HTTPException) as error: + parser.error(f'{error}; storage outcome is unknown if the request was sent') + finally: + connection.close() + print(json.dumps({'revision': revision[1:-1], 'selected': selected.decode('utf-8'), + 'stored_bytes': len(selected)}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/selection.pure b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/selection.pure new file mode 100644 index 0000000..7d9d4ec --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/selection.pure @@ -0,0 +1,19 @@ +// This is an input boundary layout, not a required storage or RAM model. +// The client sends two little-endian u32 values followed by document bytes. +layout Selection { + start: u32le; + count: u32le; + document: bytes; +} + +on get { + let saved = store.read(request.key); + reply(saved, saved.body); +} + +on put { + let input = view(Selection, request.body); + let selected = range(input.document, input.start, input.count); + let saved = store.write(request.key, request.revision, selected); + reply(saved, selected); +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/storage.pure b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/storage.pure new file mode 100644 index 0000000..ff54ea3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/examples/storage.pure @@ -0,0 +1,11 @@ +// The host supplies request bytes, an object name, and the expected revision. +// Failed storage results propagate to the host's response without executing reply. +on get { + let object = store.read(request.key); + reply(object, object.body); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.c new file mode 100644 index 0000000..e394d11 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.c @@ -0,0 +1,357 @@ +#include "program.h" +#include "store.h" +#include +#include +#include + +enum { NAME = 256, NUMBER, SYMBOLS = 32, LAYOUTS = 4, FIELDS = 16 }; +typedef enum { BYTE_VALUE, RESULT_VALUE, LAYOUT_VALUE, INDEX_VALUE } ValueKind; +typedef struct { + char name[32]; + ValueKind kind; + PureRegion region; + size_t step, layout; + PureIndex index; +} Symbol; +typedef struct { + char name[32]; + size_t offset, length; + bool number, tail; +} Field; +typedef struct { + char name[32]; + Field fields[FIELDS]; + size_t count, prefix; + bool tail; +} Layout; +typedef struct { + const char *source; + size_t length, at, line, token_line; + int token; + char name[32]; + size_t number; + char *error; + size_t error_size; + bool failed, put, effect; + Symbol symbols[SYMBOLS]; + size_t count, layout_count; + Layout layouts[LAYOUTS]; + PureProgram program; +} Parser; + +static bool reject(Parser *p, const char *message) { + if (!p->failed && p->error_size) snprintf(p->error, p->error_size, "line %zu: %s", p->token_line, message); + p->failed = true; + return false; +} + +static bool letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static bool next(Parser *p) { + while (p->at < p->length) { + char c = p->source[p->at]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (c == '\n') ++p->line; + ++p->at; + } else if (c == '/' && p->at + 1 < p->length && p->source[p->at + 1] == '/') { + while (p->at < p->length && p->source[p->at] != '\n') ++p->at; + } else break; + } + p->token_line = p->line; + p->name[0] = 0; + p->token = 0; + if (p->at == p->length) return true; + char c = p->source[p->at++]; + if (letter(c)) { + size_t start = p->at - 1; + while (p->at < p->length && (letter(p->source[p->at]) || + (p->source[p->at] >= '0' && p->source[p->at] <= '9'))) ++p->at; + size_t size = p->at - start; + if (size >= sizeof(p->name)) return reject(p, "name exceeds 31 bytes"); + memcpy(p->name, p->source + start, size); + p->name[size] = 0; + p->token = NAME; + } else if (c >= '0' && c <= '9') { + size_t value = (size_t)(c - '0'); + while (p->at < p->length && p->source[p->at] >= '0' && p->source[p->at] <= '9') { + unsigned digit = (unsigned)(p->source[p->at++] - '0'); + if (value > (SIZE_MAX - digit) / 10) return reject(p, "integer overflow"); + value = value * 10 + digit; + } + p->number = value; + p->token = NUMBER; + } else if (c && strchr("{}().,=;:[]", c)) p->token = (unsigned char)c; + else return reject(p, "unexpected character"); + return true; +} + +static bool is_word(Parser *p, const char *word) { + return p->token == NAME && !strcmp(p->name, word); +} + +static bool word(Parser *p, const char *expected) { + return is_word(p, expected) ? next(p) : reject(p, expected); +} + +static bool token(Parser *p, int expected) { + return p->token == expected ? next(p) : reject(p, "unexpected token"); +} + +static Symbol *find(Parser *p, const char *name) { + for (size_t i = 0; i < p->count; ++i) + if (!strcmp(name, p->symbols[i].name)) return &p->symbols[i]; + return NULL; +} + +static bool field(Parser *p, const char *object, const char *name) { + return word(p, object) && token(p, '.') && word(p, name); +} + +static bool require_bytes(Parser *p, const Symbol *value) { + return value->kind == BYTE_VALUE || reject(p, "expected bytes; select a byte field"); +} + +static void require_length(Parser *p, size_t required) { + size_t *minimum = p->put ? &p->program.input_required : &p->program.read_required; + if (required > *minimum) *minimum = required; +} + +static Layout *find_layout(Parser *p, const char *name) { + for (size_t i = 0; i < p->layout_count; ++i) + if (!strcmp(name, p->layouts[i].name)) return &p->layouts[i]; + return NULL; +} + +static bool layout_definition(Parser *p) { + if (p->layout_count == LAYOUTS) return reject(p, "program exceeds 4 layouts"); + if (!next(p)) return false; + if (p->token != NAME || find_layout(p, p->name)) return reject(p, "expected a unique layout name"); + Layout *layout = &p->layouts[p->layout_count]; + strcpy(layout->name, p->name); + if (!next(p) || !token(p, '{')) return false; + while (p->token != '}') { + if (layout->count == FIELDS) return reject(p, "layout exceeds 16 fields"); + if (layout->tail) return reject(p, "variable bytes must be the last field"); + if (p->token != NAME) return reject(p, "expected a field name"); + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(layout->fields[i].name, p->name)) return reject(p, "duplicate field"); + Field *f = &layout->fields[layout->count]; + strcpy(f->name, p->name); + f->offset = layout->prefix; + if (!next(p) || !token(p, ':')) return false; + if (is_word(p, "u32le")) { + f->number = true; + f->length = 4; + if (!next(p)) return false; + } else if (is_word(p, "bytes")) { + if (!next(p)) return false; + if (p->token == '[') { + if (!next(p)) return false; + if (p->token != NUMBER) return reject(p, "fixed bytes requires a constant size"); + f->length = p->number; + if (!next(p) || !token(p, ']')) return false; + } else { f->tail = true; layout->tail = true; } + } else return reject(p, "supported field types: u32le, bytes[N], bytes"); + if (f->length > PURE_OBJECT_LIMIT - layout->prefix) return reject(p, "layout exceeds object bound"); + layout->prefix += f->length; + ++layout->count; + if (!token(p, ';')) return false; + } + if (!layout->count) return reject(p, "empty layout"); + ++p->layout_count; + return token(p, '}'); +} + +static bool reference(Parser *p, Symbol *out) { + if (is_word(p, "request")) { + if (!p->put) return reject(p, "request.body is only available in on put"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return field(p, "request", "body"); + } + Symbol *symbol = p->token == NAME ? find(p, p->name) : NULL; + if (!symbol) return reject(p, "unknown value"); + *out = *symbol; + if (!next(p)) return false; + if (p->token != '.') return true; + if (!next(p)) return false; + if (symbol->kind == RESULT_VALUE) { + if (p->put) return reject(p, "write result has no body; use the input value"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return word(p, "body"); + } + if (symbol->kind != LAYOUT_VALUE || p->token != NAME) return reject(p, "expected a layout field"); + Layout *layout = &p->layouts[symbol->layout]; + Field *f = NULL; + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(p->name, layout->fields[i].name)) f = &layout->fields[i]; + if (!f) return reject(p, "unknown layout field"); + size_t offset = symbol->region.offset + f->offset; + *out = (Symbol){.kind = f->number ? INDEX_VALUE : BYTE_VALUE}; + if (f->number) out->index = (PureIndex){.value = offset, .from_u32le = true}; + else out->region = (PureRegion){.offset = offset, + .length = f->tail ? (symbol->region.to_end ? 0 : symbol->region.length - f->offset) : f->length, + .to_end = f->tail && symbol->region.to_end}; + return next(p); +} + +static bool byte_reference(Parser *p, Symbol *out) { + return reference(p, out) && require_bytes(p, out); +} + +static bool index_value(Parser *p, PureIndex *out) { + if (p->token == NUMBER) { + *out = (PureIndex){.value = p->number}; + return next(p); + } + Symbol value; + if (!reference(p, &value)) return false; + if (value.kind != INDEX_VALUE) return reject(p, "range requires an integer or u32le field"); + *out = value.index; + return true; +} + +static bool layout_view(Parser *p, Symbol *out) { + if (!next(p) || !token(p, '(')) return false; + Layout *layout = p->token == NAME ? find_layout(p, p->name) : NULL; + if (!layout) return reject(p, "unknown layout"); + if (!next(p) || !token(p, ',')) return false; + Symbol source; + if (!byte_reference(p, &source) || !token(p, ')')) return false; + if (source.step) return reject(p, "layout view requires a statically located source"); + size_t bound = source.region.to_end ? PURE_OBJECT_LIMIT - source.region.offset : source.region.length; + if (layout->prefix > bound) return reject(p, "layout exceeds source bound"); + require_length(p, source.region.offset + layout->prefix); + *out = (Symbol){.kind = LAYOUT_VALUE, .region = source.region, .layout = (size_t)(layout - p->layouts)}; + if (!layout->tail) { out->region.length = layout->prefix; out->region.to_end = false; } + return true; +} + +static bool view(Parser *p, Symbol *out) { + if (is_word(p, "view")) return layout_view(p, out); + if (!is_word(p, "range")) return reference(p, out); + Symbol parent; + PureIndex offset, length; + if (!next(p) || !token(p, '(') || !byte_reference(p, &parent) || !token(p, ',') || + !index_value(p, &offset) || !token(p, ',') || !index_value(p, &length) || !token(p, ')')) return false; + bool dynamic = parent.step || offset.from_u32le || length.from_u32le; + *out = (Symbol){.kind = BYTE_VALUE}; + if (dynamic) { + if (!p->put) return reject(p, "data-dependent ranges currently require PUT input"); + if (p->program.input_steps == PURE_DYNAMIC_LIMIT) return reject(p, "input exceeds 8 dynamic ranges"); + if ((!offset.from_u32le && offset.value > PURE_OBJECT_LIMIT) || + (!length.from_u32le && length.value > PURE_OBJECT_LIMIT)) return reject(p, "range exceeds object bound"); + PureRangeStep *step = &p->program.steps[p->program.input_steps++]; + *step = (PureRangeStep){.source = parent.region, .source_step = parent.step, + .offset = offset, .length = length}; + out->step = p->program.input_steps; + } else { + size_t bound = parent.region.to_end ? PURE_OBJECT_LIMIT - parent.region.offset : parent.region.length; + if (offset.value > bound || length.value > bound - offset.value) return reject(p, "range exceeds its source bound"); + out->region = (PureRegion){.offset = parent.region.offset + offset.value, .length = length.value}; + require_length(p, out->region.offset + out->region.length); + } + ++p->program.range_definitions; + return true; +} + +static bool effect(Parser *p, Symbol *out) { + if (p->effect) return reject(p, "only one storage effect is supported per handler"); + if (!word(p, "store") || !token(p, '.') || !word(p, p->put ? "write" : "read") || + !token(p, '(') || !field(p, "request", "key")) return false; + if (p->put) { + Symbol value; + if (!token(p, ',') || !field(p, "request", "revision") || !token(p, ',') || + !byte_reference(p, &value)) return false; + p->program.write = value.region; + p->program.write_step = value.step; + } + if (!token(p, ')')) return false; + out->kind = RESULT_VALUE; + p->effect = true; + return true; +} + +static bool handler(Parser *p) { + p->count = 0; + p->effect = false; + if (!token(p, '{')) return false; + while (is_word(p, "let")) { + if (p->put && p->effect) return reject(p, "all views must precede store.write; reply must follow it"); + if (p->count == SYMBOLS) return reject(p, "handler exceeds 32 definitions"); + if (!next(p)) return false; + if (p->token != NAME || is_word(p, "request") || is_word(p, "store") || + is_word(p, "range") || is_word(p, "view")) return reject(p, "expected a non-reserved definition name"); + if (find(p, p->name)) return reject(p, "duplicate definition"); + char name[32]; + strcpy(name, p->name); + Symbol symbol = {0}; + if (!next(p) || !token(p, '=')) return false; + if (is_word(p, "store")) { + if (!effect(p, &symbol)) return false; + } else { + if (!view(p, &symbol)) return false; + ++p->program.view_definitions; + } + if (!token(p, ';')) return false; + strcpy(symbol.name, name); + p->symbols[p->count++] = symbol; + } + if (!p->effect) return reject(p, "handler requires its storage effect"); + if (!word(p, "reply") || !token(p, '(')) return false; + Symbol *result = p->token == NAME ? find(p, p->name) : NULL; + if (!result || result->kind != RESULT_VALUE) return reject(p, "reply requires this handler's storage result"); + if (!next(p)) return false; + Symbol body = {0}; + bool has_body = p->token == ','; + if (has_body && (!next(p) || !byte_reference(p, &body))) return false; + if (!token(p, ')') || !token(p, ';') || !token(p, '}')) return false; + if (p->put) { + p->program.reply = body.region; + p->program.reply_step = body.step; + p->program.reply_input = has_body; + } else p->program.read = body.region; + return true; +} + +bool pure_program_parse(const char *source, size_t length, PureProgram *out, + char *error, size_t error_size) { + Parser p = {.source = source, .length = length, .line = 1, .token_line = 1, + .error = error, .error_size = error_size}; + if (error_size) error[0] = 0; + if (length > PURE_PROGRAM_LIMIT) return reject(&p, "program exceeds 16 KiB"); + if (!next(&p)) return false; + while (is_word(&p, "layout")) if (!layout_definition(&p)) return false; + unsigned handlers = 0; + while (p.token) { + if (!word(&p, "on")) return false; + if (!is_word(&p, "get") && !is_word(&p, "put")) return reject(&p, "expected get or put handler"); + p.put = is_word(&p, "put"); + unsigned bit = p.put ? 2 : 1; + if (handlers & bit) return reject(&p, "duplicate handler"); + handlers |= bit; + if (!next(&p) || !handler(&p)) return false; + } + if (handlers != 3) return reject(&p, "both get and put handlers are required"); + *out = p.program; /* Invalid preparation never changes the installed plan. */ + return true; +} + +bool pure_program_load(const char *path, PureProgram *out, char *error, size_t error_size) { + FILE *file = fopen(path, "rb"); + if (!file) { + if (error_size) snprintf(error, error_size, "cannot open program"); + return false; + } + char source[PURE_PROGRAM_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool bad = ferror(file) != 0; + if (fclose(file)) bad = true; + if (bad) { + if (error_size) snprintf(error, error_size, "cannot read program"); + return false; + } + return pure_program_parse(source, length, out, error, error_size); +} + diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.h b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.h new file mode 100644 index 0000000..f2b5b2f --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/program.h @@ -0,0 +1,39 @@ +#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; +} 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); +#endif diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/result.h b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/result.h new file mode 100644 index 0000000..8abb0f2 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.c new file mode 100644 index 0000000..3b7e930 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.c @@ -0,0 +1,265 @@ +#include "runtime.h" +#include + +#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->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; + if (!pure_program_input(r->program, w->resource.body, &value, &outcome.body)) + outcome.status = PURE_LIMIT; + 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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.h b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.h new file mode 100644 index 0000000..4462e96 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/runtime.h @@ -0,0 +1,86 @@ +#ifndef PURE_RUNTIME_H +#define PURE_RUNTIME_H +#include "program.h" +#include "result.h" +#include + +/* 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); +} 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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/server.c new file mode 100644 index 0000000..a65b634 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/server.c @@ -0,0 +1,368 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include "program.h" +#include "store-port.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received; + unsigned char *body; + bool put; + int64_t expected; + PureTicket ticket; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + PureProgram program; + PureRuntime runtime; + PureWork work[SLOTS]; + size_t budget, active, peak_active; + uint64_t accepted, expired, prefix_copy_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + if (c->ticket.runtime) { + bool released = pure_runtime_release(c->ticket); + assert(released); + (void)released; + } + c->ticket = (PureTicket){0}; + c->body = NULL; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void answer(Connection *c, PureOutcome result) { + switch (result.status) { + case PURE_OK: respond(c, 200, "OK", "none", result.revision, result.body); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + respond(c, 412, "Precondition Failed", "conflict", result.revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "store_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_ALLOCATION: fail(c, 503, "Service Unavailable", "allocation"); break; + case PURE_CLOSED: fail(c, 503, "Service Unavailable", "stopped"); break; + case PURE_DENIED: fail(c, 403, "Forbidden", "denied"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + case PURE_UNCERTAIN: fail(c, 500, "Internal Server Error", "durability_unknown"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +static void run_request(Connection *c) { + if (c->put && !pure_runtime_ready(c->ticket)) abort(); + PureOutcome result; + while (!pure_runtime_result(c->ticket, &result)) + if (!pure_runtime_step(&c->server->runtime)) abort(); + c->body = NULL; /* Core now owns input until all consumers release it. */ + answer(c, result); +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + PureResult admitted = pure_runtime_accept(&c->server->runtime, + c->put ? PURE_INPUT_WRITE : PURE_INPUT_READ, c->key, c->expected, c->body_size, &c->ticket); + if (admitted != PURE_OK) { answer(c, (PureOutcome){.status = admitted}); return; } + if (!c->put) { run_request(c); return; } + c->body = pure_runtime_ingress(c->ticket, NULL); + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + const char *program_path = NULL; + bool explain = argc == 3 && !strcmp(argv[1], "--check-program"); + char program_error[160]; + if (explain) program_path = argv[2]; + for (int i = 1; !explain && i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--store")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--program")) program_path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!program_path || (!explain && !server.path)) goto usage; + if (!pure_program_load(program_path, &server.program, program_error, sizeof(program_error))) { + fprintf(stderr, "Program: %s\n", program_error); return 2; + } + if (explain) { + PureProgram *p = &server.program; + printf("{\"read\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"write\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"reply_input\":%s,\"view_definitions\":%zu,\"range_definitions\":%zu," + "\"runtime_view_steps\":%zu,\"write_step\":%zu,\"reply_step\":%zu,\"prepared_program_bytes\":%zu,\"steps\":[", + p->read.to_end ? "true" : "false", p->read.offset, p->read.length, p->read_required, + p->write.to_end ? "true" : "false", p->write.offset, p->write.length, p->input_required, + p->reply_input ? "true" : "false", p->view_definitions, p->range_definitions, + p->input_steps, p->write_step, p->reply_step, sizeof(*p)); + for (size_t i = 0; i < p->input_steps; ++i) { + const PureRangeStep *step = &p->steps[i]; + printf("%s{\"source_step\":%zu,\"source\":{\"offset\":%zu,\"length\":%zu,\"to_end\":%s}," + "\"offset\":{\"value\":%zu,\"from_u32le\":%s},\"length\":{\"value\":%zu,\"from_u32le\":%s}}", + i ? "," : "", step->source_step, step->source.offset, step->source.length, + step->source.to_end ? "true" : "false", step->offset.value, step->offset.from_u32le ? "true" : "false", + step->length.value, step->length.from_u32le ? "true" : "false"); + } + puts("]}"); + return 0; + } + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize byte store directory\n", stderr); return 1; } + if (!pure_runtime_init(&server.runtime, server.work, SLOTS, &server.program, + pure_store_host(server.path), server.budget)) return 1; + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"storage\":\"immutable-byte-extents-v1\"}\n", ntohs(address.sin_port)); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + pure_runtime_stop(&server.runtime); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.runtime.retained, server.runtime.peak_retained, server.budget, server.peak_active, + server.accepted, server.runtime.commits, server.runtime.gets, server.runtime.conflicts, server.runtime.budget_rejections, + server.expired, server.runtime.releases, server.prefix_copy_bytes, server.runtime.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --program FILE --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n" + "pure-connections --check-program FILE\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.c new file mode 100644 index 0000000..06ff114 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.c @@ -0,0 +1,39 @@ +#include "store-port.h" +#include "store.h" +#include +#include + +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); +} + +PureHost pure_store_host(const char *path) { + return (PureHost){.context = (void *)path, .allocate = allocate, + .read = read_value, .write = write_value}; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.h b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store-port.h new file mode 100644 index 0000000..4c0d49d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.c new file mode 100644 index 0000000..1c13a80 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.c @@ -0,0 +1,228 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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); +} + +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, 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; + 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; + } + fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (fd >= 0) { + size_t size; + if (!read_header(fd, revision, &size)) goto finish; + close(fd); + fd = -1; + } else if (errno != ENOENT) goto finish; + if (*revision != expected) { result = PURE_CONFLICT; goto finish; } + if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + 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, body.size); + if (!write_bytes(fd, header, sizeof(header)) || fault(PURE_AFTER_HEADER) || + !write_bytes(fd, body.data, body.size) || 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 (*temporary) unlinkat(directory, temporary, 0); + if (lock >= 0) close(lock); + close(directory); + return result; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.h new file mode 100644 index 0000000..1c0e054 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/store.h @@ -0,0 +1,37 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include "result.h" +#include + +#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); + +/* 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/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-fields.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-fields.c new file mode 100644 index 0000000..63dfc3e --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-fields.c @@ -0,0 +1,153 @@ +#include "program.h" +#include +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static const char layout[] = "layout Envelope { marker: bytes[1]; start: u32le; count: u32le; text: bytes; }"; +static PureProgram parse(const char *source) { + PureProgram p; + char error[160]; + if (!pure_program_parse(source, strlen(source), &p, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return p; +} +static void invalid(const char *source) { + PureProgram p; + memset(&p, 0x5a, sizeof(p)); + unsigned char before[sizeof(p)]; + memcpy(before, &p, sizeof(p)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(*error && !memcmp(before, &p, sizeof(p))); +} +static void encode(unsigned char *at, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) at[i] = (unsigned char)(value >> (8 * i)); +} +int main(void) { + char source[8000]; + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let alias = input; let start = alias.start; let text = alias.text; " + "let selected = range(text, start, alias.count); let result = selected; " + "let saved = store.write(request.key, request.revision, result); reply(saved, selected); }", layout, get); + PureProgram p = parse(source); + CHECK(p.input_steps == 1 && p.input_required == 9 && p.write_step == 1 && p.reply_step == 1); + CHECK(p.steps[0].source.offset == 9 && p.steps[0].source.to_end); + CHECK(p.steps[0].offset.value == 1 && p.steps[0].offset.from_u32le); + CHECK(p.steps[0].length.value == 5 && p.steps[0].length.from_u32le); + unsigned char bytes[609]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 13); + PureSpan stored, returned; + for (size_t n = 0; n < 9; ++n) { + CHECK(!pure_program_input(&p, (PureSpan){bytes, n}, &stored, &returned)); + CHECK(!stored.size && !returned.size); + } + const uint32_t offsets[] = {0, 1, 255, 257, 599, 600, 601, UINT32_MAX}; + const uint32_t counts[] = {0, 1, 2, 260, 600, UINT32_MAX}; + size_t comparisons = 0; + for (size_t i = 0; i < sizeof(offsets) / sizeof(*offsets); ++i) + for (size_t j = 0; j < sizeof(counts) / sizeof(*counts); ++j) { + uint32_t start = offsets[i], count = counts[j]; + encode(bytes + 1, start); + encode(bytes + 5, count); + bool expected = start <= 600 && count <= 600 - start; + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned) == expected); + if (expected) { + CHECK(stored.size == count && returned.size == count && stored.data == returned.data); + CHECK(!count || stored.data == bytes + 9 + start); + } else CHECK(!stored.size && !returned.size); + ++comparisons; + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let first = range(input.text, input.start, input.count); let second = range(first, 1, 2); " + "let saved = store.write(request.key, request.revision, second); reply(saved, second); }", layout, get); + p = parse(source); + CHECK(p.input_steps == 2 && p.steps[1].source_step == 1); + encode(bytes + 1, 3); encode(bytes + 5, 4); + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + CHECK(stored.size == 2 && stored.data == bytes + 13); + encode(bytes + 5, 2); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let unused = range(input.text, input.start, input.count); " + "let saved = store.write(request.key, request.revision, input.text); reply(saved); }", layout, get); + p = parse(source); + encode(bytes + 1, UINT32_MAX); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); " + "let input = view(Envelope, o.body); reply(o, input.text); } %s", layout, put); + p = parse(source); + CHECK(p.read.to_end && p.read.offset == 9 && p.read_required == 9 && !p.input_steps); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let selection = range(input.text, 2, 3); let saved = store.write(request.key, request.revision, selection); reply(saved); }", layout, get); + p = parse(source); + CHECK(!p.input_steps && p.write.offset == 11 && p.write.length == 3 && p.input_required == 14); + snprintf(source, sizeof(source), "layout Empty { body: bytes; } %s on put { let input = view(Empty, request.body); " + "let saved = store.write(request.key, request.revision, input.body); reply(saved, input.body); }", get); + p = parse(source); + CHECK(pure_program_input(&p, (PureSpan){0}, &stored, &returned) && !stored.size && !returned.size); + + const char *bad_layout[] = { + "layout E {}", "layout E { x: bytes; y: u32le; }", "layout E { x: u32le; x: bytes; }", + "layout E { x: bytes[1449985]; }", "layout E { x: bytes[1449984]; y: u32le; }", + "layout E { x: bytes[184467440737095516160]; }", "layout E { x: u64le; }", + "layout E { x: u32le; } layout E { y: bytes; }" + }; + for (size_t i = 0; i < sizeof(bad_layout) / sizeof(*bad_layout); ++i) { + snprintf(source, sizeof(source), "%s %s %s", bad_layout[i], get, put); invalid(source); + } + const char *bad_input[] = { + "let input = view(Missing, request.body);", + "let input = view(Envelope, request.body); let x = input.missing;", + "let input = view(Envelope, request.body); let x = range(input.text, input.text, 1);", + "let short = range(request.body, 0, 8); let input = view(Envelope, short);", + "let input = view(Envelope, request.body); let x = range(input.text, input.start, 20); let y = view(Envelope, x);" + }; + for (size_t i = 0; i < sizeof(bad_input) / sizeof(*bad_input); ++i) { + snprintf(source, sizeof(source), "%s %s on put { %s let saved = store.write(request.key, request.revision, request.body); reply(saved); }", layout, get, bad_input[i]); + invalid(source); + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let saved = store.write(request.key, request.revision, input.start); reply(saved); }", layout, get); + invalid(source); + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); let input = view(Envelope, o.body); " + "let x = range(input.text, input.start, input.count); reply(o, x); } %s", layout, put); + invalid(source); + for (size_t count = 8; count <= 9; ++count) { + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); ", layout, get); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let v%zu = range(input.text, input.start, input.count); ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let saved = store.write(request.key, request.revision, input.text); reply(saved); }"); + if (count == 8) CHECK(parse(source).input_steps == 8); else invalid(source); + } + for (size_t count = 4; count <= 5; ++count) { + source[0] = 0; + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "layout L%zu { body: bytes; } ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "%s %s", get, put); + if (count == 4) (void)parse(source); else invalid(source); + } + for (size_t count = 16; count <= 17; ++count) { + strcpy(source, "layout L { "); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "f%zu: u32le; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "} %s %s", get, put); + if (count == 16) (void)parse(source); else invalid(source); + } + printf("{\"fields\":\"named-layouts-unaligned-u32le-tail-dynamic-ranges-aliases-failures-limits\"," + "\"data_boundary_cases\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..5ff028d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-flow.c @@ -0,0 +1,183 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + long page = sysconf(_SC_PAGESIZE); + CHECK(page >= 128 && (size_t)page <= PURE_OBJECT_LIMIT / 3); + size_t size = (size_t)page * 3, offset = (size_t)page + 13, length = (size_t)page + 105; + unsigned char *bytes = malloc(size); + CHECK(bytes); + for (size_t i = 0; i < size; ++i) bytes[i] = (unsigned char)((i * 17u) ^ (i >> 8) ^ (i >> 16)); + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, size}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", offset, length, length, &first) == PURE_OK); + CHECK(first.body.size == length && !memcmp(first.body.data, bytes + offset, length)); + pure_store_release(&first); + free(bytes); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-program.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-program.c new file mode 100644 index 0000000..6a4a19d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-program.c @@ -0,0 +1,111 @@ +#include "program.h" +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static PureProgram parse(const char *source) { + PureProgram program; + char error[160]; + if (!pure_program_parse(source, strlen(source), &program, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return program; +} +static void invalid(const char *source) { + PureProgram program; + memset(&program, 0x5a, sizeof(program)); + unsigned char original[sizeof(program)]; + memcpy(original, &program, sizeof(program)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &program, error, sizeof(error))); + CHECK(*error && !memcmp(original, &program, sizeof(program))); +} +int main(void) { + char source[4096]; + snprintf(source, sizeof(source), "%s %s", get, put); + PureProgram plan = parse(source); + CHECK(plan.read.to_end && plan.write.to_end && !plan.reply_input); + CHECK(!plan.view_definitions && !plan.read_required && !plan.input_required); + snprintf(source, sizeof(source), "%s on put { let a = range(request.body, 2, 10); " + "let b = range(a, 3, 4); let alias = b; " + "let saved = store.write(request.key, request.revision, alias); reply(saved, b); }", get); + plan = parse(source); + CHECK(plan.write.offset == 5 && plan.write.length == 4 && !plan.write.to_end); + CHECK(plan.reply.offset == 5 && plan.reply.length == 4 && plan.reply_input); + CHECK(plan.input_required == 12 && plan.range_definitions == 2 && plan.view_definitions == 3); + /* Ordered reference views keep even unused bounds failures observable. */ + unsigned char bytes[160]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 7); + size_t comparisons = 0; + for (size_t a = 0; a < 4; ++a) for (size_t b = 0; b < 6; ++b) + for (size_t c = 0; c <= b; ++c) for (size_t d = 0; d <= b - c; ++d) { + size_t unused_end = (a + b + c) % 12; + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); " + "let unused = range(o.body, %zu, 0); let first = range(o.body, %zu, %zu); " + "let second = range(first, %zu, %zu); reply(o, second); } %s", + unused_end, a, b, c, d, put); + plan = parse(source); + for (size_t n = 0; n < 16; ++n) { + bool reference_ok = unused_end <= n && a <= n && b <= n - a; + PureSpan selected; + bool prepared_ok = n >= plan.read_required && + pure_region_span(plan.read, (PureSpan){bytes, n}, &selected); + CHECK(reference_ok == prepared_ok); + if (reference_ok) { + CHECK(selected.size == d); + CHECK(!d || !memcmp(selected.data, bytes + a + c, d)); + } + ++comparisons; + } + } + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); reply(o); } %s", put); + plan = parse(source); + CHECK(!plan.read.to_end && !plan.read.length); + const char *bad_get[] = { + "on get { let o = store.read(request.key); let x = range(o.body, 0, 2); let y = range(x, 2, 1); reply(o, y); }", + "on get { let o = store.read(request.key); let x = range(o.body, 184467440737095516160, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let x = range(o.body, 1449984, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let o = o.body; reply(o); }", + "on get { let o = store.read(request.key); let x = x; reply(o, x); }", + "on get { let o = store.read(request.key); let x = store.read(request.key); reply(x); }", + "on get { let o = store.read(request.key); reply(o, request.body); }", + "on get { let o = store.read(request.key); let x = o.body; reply(x); }", + "on get { let o = store.read(request.key); reply(o.body); }", + "on get { let o = store.read(request.key); reply(o); let x = o.body; }", + "on get { let o = store.read(request.key); reply(o);", "on get {}" + }; + for (size_t i = 0; i < sizeof(bad_get) / sizeof(*bad_get); ++i) { + snprintf(source, sizeof(source), "%s %s", bad_get[i], put); invalid(source); + } + const char *bad_put[] = { + "on put { let s = store.write(request.key, request.revision, request.body); let b = range(request.body, 0, 1); reply(s, b); }", + "on put { let s = store.write(request.key, request.revision, request.body); reply(s, s.body); }", + "on put { let s = store.read(request.key); reply(s); }" + }; + for (size_t i = 0; i < sizeof(bad_put) / sizeof(*bad_put); ++i) { + snprintf(source, sizeof(source), "%s %s", get, bad_put[i]); invalid(source); + } + invalid(get); + snprintf(source, sizeof(source), "%s %s %s", get, get, put); invalid(source); + snprintf(source, sizeof(source), "%s %s garbage", get, put); invalid(source); + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); "); + for (size_t i = 0; i < 32; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let x%zu = o.body; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "reply(o); } %s", put); invalid(source); + char oversized[PURE_PROGRAM_LIMIT + 1]; + memset(oversized, ' ', sizeof(oversized)); + char error[160]; + CHECK(!pure_program_parse(oversized, sizeof(oversized), &plan, error, sizeof(error))); + const char embedded[] = "on get\0{}"; + CHECK(!pure_program_parse(embedded, sizeof(embedded) - 1, &plan, error, sizeof(error))); + PureSpan selected; + CHECK(pure_region_span((PureRegion){0}, (PureSpan){0}, &selected) && !selected.data && !selected.size); + printf("{\"program\":\"prepare-views-preserve-guards-types-effects-and-atomic-install\"," + "\"boundary_semantic_comparisons\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-runtime.c b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-runtime.c new file mode 100644 index 0000000..7ce2290 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/connections/test-runtime.c @@ -0,0 +1,285 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include + +static const PureProgram identity = { + .read = {.to_end = true}, .write = {.to_end = true}, + .reply = {.to_end = true}, .reply_input = true +}; +typedef struct { + size_t allocations, writes, frees, live; + bool allocation_failure, oversized_read, reenter; + PureRuntime *runtime; + unsigned char received[256]; + size_t received_size; +} Host; +typedef struct { Host *host; unsigned char bytes[]; } Allocation; + +static void free_input(void *pointer, size_t size) { + Allocation *a = pointer; + assert(size > 0 && a->host->live > 0); + ++a->host->frees; + --a->host->live; + free(a); +} + +static bool allocate(void *context, size_t size, PureResource *out) { + Host *host = context; + if (host->allocation_failure) return false; + Allocation *a = malloc(sizeof(*a) + size); + assert(a); + a->host = host; + ++host->allocations; + ++host->live; + *out = (PureResource){.body = {a->bytes, size}, .allocation = a, + .allocation_size = size, .release = free_input}; + return true; +} + +static PureResult write_value(void *context, const char *key, int64_t expected, + PureSpan body, int64_t *revision) { + Host *host = context; + (void)key; + if (host->reenter) { + PureTicket ignored; + assert(pure_runtime_accept(host->runtime, PURE_INPUT_WRITE, "reenter", 0, 0, &ignored) == PURE_INVALID); + assert(!pure_runtime_step(host->runtime)); + } + if (expected == 99) { *revision = 1; return PURE_CONFLICT; } + ++host->writes; + assert(body.size <= sizeof(host->received)); + if (body.size) memcpy(host->received, body.data, body.size); + host->received_size = body.size; + *revision = 1; + return PURE_OK; +} + +static PureResult read_value(void *context, const char *key, PureRegion range, + size_t minimum, size_t available, PureResource *out, int64_t *revision) { + Host *host = context; + (void)key; (void)range; (void)minimum; + if (!host->oversized_read) return PURE_NOT_FOUND; + assert(available < 1000); + assert(allocate(host, available + 1, out)); + *revision = 1; + return PURE_OK; /* Deliberately violates the available-credit contract. */ +} + +static PureHost port(Host *host) { + return (PureHost){host, allocate, read_value, write_value}; +} + +static PureTicket submit(PureRuntime *r, const char *key, const void *bytes, size_t length) { + PureTicket ticket; + assert(pure_runtime_accept(r, PURE_INPUT_WRITE, key, 0, length, &ticket) == PURE_OK); + size_t capacity; + unsigned char *input = pure_runtime_ingress(ticket, &capacity); + assert(capacity == length); + if (length) memcpy(input, bytes, length); + assert(pure_runtime_ready(ticket)); + assert(!pure_runtime_ready(ticket)); + return ticket; +} + +static PureOutcome completed(PureRuntime *r, PureTicket ticket) { + PureOutcome result; + while (!pure_runtime_result(ticket, &result)) assert(pure_runtime_step(r)); + return result; +} + +static void admission_and_lifecycle(void) { + Host host = {0}; + PureRuntime r; + PureWork work[3]; + assert(pure_runtime_init(&r, work, 3, &identity, port(&host), 5)); + host.runtime = &r; + PureTicket a, untouched = {0}, out = untouched; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "../bad", 0, 1, &out) == PURE_INVALID); + assert(!out.runtime && !host.allocations); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "a", 0, 4, &a) == PURE_OK); + assert(r.retained == 4 && !pure_runtime_step(&r) && !host.writes); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "b", 0, 2, &out) == PURE_BUDGET); + assert(!out.runtime && host.allocations == 1); + assert(pure_runtime_cancel(a)); + PureOutcome cancelled; + assert(pure_runtime_result(a, &cancelled) && cancelled.status == PURE_CANCELLED); + assert(r.retained == 0 && host.frees == 1 && !pure_runtime_cancel(a)); + assert(pure_runtime_release(a) && !pure_runtime_release(a)); + assert(!pure_runtime_result(a, &cancelled)); + + PureTicket first = submit(&r, "first", "1", 1); + PureTicket middle = submit(&r, "middle", "2", 1); + PureTicket last = submit(&r, "last", "3", 1); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "full", 0, 0, &out) == PURE_BUSY); + assert(pure_runtime_cancel(middle)); + assert(pure_runtime_release(middle)); + pure_runtime_stop(&r); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "closed", 0, 0, &out) == PURE_CLOSED); + assert(!pure_runtime_restart(&r, &identity, port(&host), 5)); + host.reenter = true; + assert(completed(&r, first).status == PURE_OK && host.received[0] == '1'); + assert(completed(&r, last).status == PURE_OK && host.received[0] == '3'); + assert(!pure_runtime_step(&r)); + assert(pure_runtime_release(first) && pure_runtime_release(last)); + assert(r.active == 0 && r.retained == 0 && host.live == 0); + assert(pure_runtime_restart(&r, &identity, port(&host), 5)); + PureTicket next = submit(&r, "next", "4", 1); + assert(next.generation > last.generation && !pure_runtime_ready(a)); + assert(!pure_runtime_release(last)); + assert(pure_runtime_release(next)); /* queued input: no effect */ + assert(host.writes == 2 && !pure_runtime_step(&r)); + + host.allocation_failure = true; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "no_memory", 0, 1, &out) == PURE_ALLOCATION); + assert(r.active == 0 && r.retained == 0); + host.allocation_failure = false; + PureHost denied = port(&host); + denied.write = NULL; + assert(pure_runtime_restart(&r, &identity, denied, 5)); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "denied", 0, 1, &out) == PURE_DENIED); + assert(pure_runtime_restart(&r, &identity, port(&host), 5)); + host.oversized_read = true; + assert(pure_runtime_accept(&r, PURE_INPUT_READ, "bad_host", 0, 0, &out) == PURE_OK); + assert(completed(&r, out).status == PURE_BUDGET && host.live == 0 && r.retained == 0); + assert(pure_runtime_release(out)); + r.serial = UINT64_MAX; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "exhausted", 0, 0, &out) == PURE_LIMIT); +} + +static void forwarding(void) { + Host a = {0}, b = {0}, c = {0}; + PureRuntime source, left, right; + PureWork source_work[1], left_work[1], right_work[1]; + assert(pure_runtime_init(&source, source_work, 1, &identity, port(&a), 8)); + assert(pure_runtime_init(&left, left_work, 1, &identity, port(&b), 2)); + assert(pure_runtime_init(&right, right_work, 1, &identity, port(&c), 8)); + PureTicket original = submit(&source, "original", "value", 5), x = {0}, y; + PureOutcome result = completed(&source, original); + assert(result.status == PURE_OK); + const unsigned char *backing = result.body.data; + assert(pure_runtime_forward(original, &left, "left", 0, &x) == PURE_BUDGET); + assert(!x.runtime && source.work[0].pins == 0); + assert(pure_runtime_restart(&left, &identity, port(&b), 8)); + assert(pure_runtime_forward(original, &left, "left", 0, &x) == PURE_OK); + assert(pure_runtime_forward(original, &right, "right", 0, &y) == PURE_OK); + assert(pure_runtime_release(original)); + assert(!pure_runtime_result(original, &result)); + pure_runtime_stop(&source); + assert(!pure_runtime_restart(&source, &identity, port(&a), 8)); + assert(a.live == 1 && !b.allocations && !c.allocations); + assert(pure_runtime_cancel(x) && pure_runtime_release(x)); + assert(a.live == 1 && !b.writes); + result = completed(&right, y); + assert(result.body.data == backing && result.body.size == 5 && !memcmp(result.body.data, "value", 5)); + assert(c.writes == 1 && !c.allocations && source.retained == 5); + assert(!pure_runtime_cancel(y)); /* effects cannot be rolled back */ + assert(pure_runtime_release(y)); + assert(a.frees == 1 && !source.active && !source.retained && !right.active && !right.retained); + assert(pure_runtime_restart(&source, &identity, port(&a), 8)); + + /* A completed consumer does not retain its input when the program has no + * input-backed reply. The producing domain can then be reclaimed early. */ + PureProgram no_reply = identity; + no_reply.reply_input = false; + assert(pure_runtime_restart(&right, &no_reply, port(&c), 8)); + original = submit(&source, "again", "last", 4); + assert(completed(&source, original).status == PURE_OK); + assert(pure_runtime_forward(original, &right, "no_reply", 0, &y) == PURE_OK); + assert(pure_runtime_release(original)); + assert(completed(&right, y).body.size == 0 && a.live == 0); + assert(source.active == 0 && right.active == 1 && right.retained == 0); + assert(pure_runtime_release(y)); +} + +static void long_chain(void) { + enum { COUNT = 1024 }; + PureWork *work = calloc(COUNT, sizeof(*work)); + assert(work); + Host host = {0}; + PureRuntime r; + assert(pure_runtime_init(&r, work, COUNT, &identity, port(&host), COUNT)); + PureTicket ticket = submit(&r, "chain", "x", 1); + for (size_t i = 1; i < COUNT; ++i) { + assert(completed(&r, ticket).status == PURE_OK); + PureTicket next; + assert(pure_runtime_forward(ticket, &r, "chain", 0, &next) == PURE_OK); + assert(pure_runtime_release(ticket)); + ticket = next; + } + assert(completed(&r, ticket).status == PURE_OK); + assert(host.allocations == 1 && r.active == COUNT); + assert(pure_runtime_release(ticket)); + assert(r.retained == 0 && r.active == 0 && host.frees == 1); + free(work); +} + +static void actual_storage(void) { + char directory[] = "/tmp/pure-runtime-check-XXXXXX"; + assert(mkdtemp(directory)); + char source_path[128], target_path[128], name[160]; + snprintf(source_path, sizeof(source_path), "%s/source", directory); + snprintf(target_path, sizeof(target_path), "%s/target", directory); + assert(pure_store_init(source_path) == PURE_OK && pure_store_init(target_path) == PURE_OK); + PureProgram selection; + char error[160]; + assert(pure_program_load("examples/selection.pure", &selection, error, sizeof(error))); + PureRuntime source, target; + PureWork a[2], b[2]; + assert(pure_runtime_init(&source, a, 2, &selection, pure_store_host(source_path), 128)); + assert(pure_runtime_init(&target, b, 2, &identity, pure_store_host(target_path), 128)); + const unsigned char bytes[] = {1,0,0,0, 3,0,0,0, 'a','b','c','d','e'}; + PureTicket ticket = submit(&source, "excerpt", bytes, sizeof(bytes)), consumer; + PureOutcome result = completed(&source, ticket); + assert(result.status == PURE_OK && result.body.size == 3 && !memcmp(result.body.data, "bcd", 3)); + assert(pure_runtime_forward(ticket, &target, "archive", 0, &consumer) == PURE_OK); + assert(pure_runtime_release(ticket)); + assert(source.active == 1 && source.retained == sizeof(bytes)); + assert(completed(&target, consumer).status == PURE_OK); + assert(pure_runtime_release(consumer) && source.active == 0); + PureStoredView stored = {0}; + assert(pure_store_get(target_path, "archive", 128, &stored) == PURE_OK); + assert(stored.body.size == 3 && !memcmp(stored.body.data, "bcd", 3)); + pure_store_release(&stored); + + assert(pure_runtime_accept(&source, PURE_INPUT_READ, "excerpt", 0, 0, &ticket) == PURE_OK); + assert(completed(&source, ticket).status == PURE_OK); + assert(pure_runtime_forward(ticket, &target, "snapshot", 0, &consumer) == PURE_OK); + assert(pure_runtime_release(ticket)); + int64_t revision; + assert(pure_store_put(source_path, "excerpt", 1, (PureSpan){(const unsigned char *)"new", 3}, &revision) == PURE_OK); + result = completed(&target, consumer); + assert(result.body.size == 3 && !memcmp(result.body.data, "bcd", 3)); + assert(pure_runtime_release(consumer)); + + unsigned char invalid[] = {255,255,255,255, 1,0,0,0, 'x'}; + ticket = submit(&source, "invalid", invalid, sizeof(invalid)); + assert(completed(&source, ticket).status == PURE_LIMIT); + assert(pure_runtime_release(ticket)); + assert(pure_store_get(source_path, "invalid", 128, &stored) == PURE_NOT_FOUND); + assert(source.active == 0 && target.active == 0 && source.retained == 0 && target.retained == 0); + const char *files[] = {"source/obj-excerpt", "source/.writer.lock", "target/obj-archive", + "target/obj-snapshot", "target/.writer.lock"}; + for (size_t i = 0; i < sizeof(files) / sizeof(files[0]); ++i) { + snprintf(name, sizeof(name), "%s/%s", directory, files[i]); + assert(unlink(name) == 0); + } + assert(rmdir(source_path) == 0 && rmdir(target_path) == 0 && rmdir(directory) == 0); +} + +int main(void) { + admission_and_lifecycle(); + forwarding(); + long_chain(); + actual_storage(); + printf("{\"runtime_tests\":\"passed\",\"domain_bytes\":%zu,\"work_slot_bytes\":%zu," + "\"ticket_bytes\":%zu,\"prepared_program_bytes\":%zu,\"forwarding_chain\":1024," + "\"native_storage_pipeline\":true}\n", sizeof(PureRuntime), sizeof(PureWork), + sizeof(PureTicket), sizeof(PureProgram)); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/core.wasm new file mode 100755 index 0000000..79c50a8 Binary files /dev/null and b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-execution-core/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/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/connections/results/2026-09-23-execution-core/verification.json b/prototypes/connections/results/2026-09-23-execution-core/verification.json new file mode 100644 index 0000000..ad430f8 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-execution-core/verification.json @@ -0,0 +1,30 @@ +{ + "sources_verified_and_frozen": 30, + "native_and_sanitized_binaries_match": true, + "tcp_scenarios_per_build": 28, + "all_http_runs_drained": true, + "core_archive_external_symbols": [ + "memcpy", + "strlen" + ], + "core_archive_sha256": "59eccbea380378a6650c59a99eacd92132184e5b9aa64feada7a176cf03ccfcc", + "size_output": " text\t data\t bss\t dec\t hex\tfilename\n 4892\t 0\t 0\t 4892\t 131c\t/home/eunhhu/work/pure/prototypes/connections/build/runtime.o\n 780\t 0\t 0\t 780\t 30c\t/home/eunhhu/work/pure/prototypes/connections/build/evaluate.o\n 41144\t 1048\t 16\t 42208\t a4e0\t/home/eunhhu/work/pure/prototypes/connections/build/pure-connections\n", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "structure_sizes": { + "runtime_tests": "passed", + "domain_bytes": 192, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 648, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "limits": [ + "synchronous storage effects", + "one owner thread", + "storage authoring profile only", + "no TLS or native UI yet", + "sizes are not process RSS or performance comparisons" + ] +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/README.md b/prototypes/connections/results/2026-09-23-field-connections/README.md new file mode 100644 index 0000000..d516556 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/README.md @@ -0,0 +1,31 @@ +# 021 검증 자료 + +이름 기반 필드와 실제 입력 값으로 계산하는 구간을 네이티브 저장·응답에 연결한 기록이다. +시간·처리량·전체 RSS 비교는 하지 않았다. + +- [native.json](native.json): 일반 빌드의 28개 통합 시나리오, 준비 결과, 자원 회수, 소스·실행 파일 해시. +- [sanitized.json](sanitized.json): ASan·UBSan 빌드의 같은 시나리오. 누수 검사를 끄지 않았다. +- [verification.json](verification.json): 명령, 세 단위 검사 결과, 소스 사본·실행 파일 일치 확인. +- `sources/`: 기록된 소스·예제·클라이언트·Wasm 사본. 이후 작업 트리가 바뀌면 별도 트리에 배치해 재현한다. + +기존 22개 검사에 6개 필드 시나리오를 추가했다. 실제 예제 클라이언트가 한글·이모지 문서의 +문자 위치를 바이트 위치로 바꿔 전송하고, 네이티브 서버가 필드 값에 따라 선택·저장·응답한다. +잘린 헤더·잘못된 범위·정렬되지 않은 숫자·바뀐 필드 순서·빈 선택·사용하지 않는 계산의 오류를 +확인한다. 별칭은 추가 동적 단계를 만들지 않으며, 느린 응답 동안 원본 수신 할당을 유지·계상한다. + +`field_connections`에는 선언에서 준비한 실제 접근 위치·데이터 의존 단계가 있다. `steps`의 +source_step 0은 입력의 정적 구간이고 양수는 앞 단계의 결과다. offset/length의 from_u32le가 +참이면 value는 입력에서 숫자를 읽을 위치이며, 거짓이면 상수다. write_step/reply_step이 양수인 +결과는 정적 offset/length 대신 해당 단계에서 정한다. 진단의 이전 whole 표기는 to_end로 바뀌었다. + +`runtime_view_steps`는 준비된 단계 수로 실행 시간 계측값이 아니다. `prepared_program_bytes` +648 B는 고정 구조체 하나의 크기이며 사용하지 않는 동적 단계 자리도 포함한다. 기존 112 B보다 +536 B 늘어난 기능 비용이다. 논리적 본문 예산에는 OS·페이지·클라이언트 메모리가 포함되지 않는다. + +`test-flow`는 기존 저장·전송 의미를, `test-program`은 상수 구간 준비 의미를, +`test-fields`는 필드 타입·배치·숫자·동적 범위·한도를 검사한다. 일반/검사 빌드 모두 통과했다. + +[구현 README](../../README.md)의 `make check`로 재현한다. `EVIDENCE=/새/경로.json`으로 +새 원자료를 남기며 기존 파일을 덮어쓰지 않는다. 이 환경에서는 소켓·자식 프로세스· +LeakSanitizer의 샌드박스 제한 때문에 승인된 환경에서 로컬 검사를 실행했다. +외부 서비스·macOS·실제 전원 장애·대규모 서비스는 검증하지 않았다. diff --git a/prototypes/connections/results/2026-09-23-field-connections/native.json b/prototypes/connections/results/2026-09-23-field-connections/native.json new file mode 100644 index 0000000..2d8ff24 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/native.json @@ -0,0 +1,488 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ] + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ] + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 18, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 39824 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "d478daf835c30152d05aeba6d2c07dd598166129f18cc5f7cb3b893966b60a18", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b7e7d6806cf9785df1d01f2418d1e493eaf2d4b9cf43abc90082c60bd61dcd0b", + "prototypes/connections/store.h": "03e23e349a19ffc3491d1bb9e4200b834cedf84a3f026613470a37a727ded11e", + "prototypes/connections/program.c": "54df0c5cd4579f64b2f32b965c520e2a4f36794cd3dece944d062454dc01df63", + "prototypes/connections/program.h": "2c3c955967883a9a8ea4566a006eccd44f3a173ac0682c2f272d09700f209639", + "prototypes/connections/server.c": "b905fa656fa626111ba765d6379aa03bd32fee97d19d48461cd752e75918ea31", + "prototypes/connections/check.py": "bf890ee92009fd57afcb6708ad325af360bf43a7c667c79c5dc90631889f5a3f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "8c0353ae6643351ba69505de4092aa40e34e5318f27d670de944cd875c4d51cc", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sanitized.json b/prototypes/connections/results/2026-09-23-field-connections/sanitized.json new file mode 100644 index 0000000..1fbb30f --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sanitized.json @@ -0,0 +1,488 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ] + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 648, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ] + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 648, + "steps": [] + } + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 18, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 39824 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 39824 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "be8a599f33d340e04f9269de9cc9b66d817aea567ca9a26a6a26258c2011ed91", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b7e7d6806cf9785df1d01f2418d1e493eaf2d4b9cf43abc90082c60bd61dcd0b", + "prototypes/connections/store.h": "03e23e349a19ffc3491d1bb9e4200b834cedf84a3f026613470a37a727ded11e", + "prototypes/connections/program.c": "54df0c5cd4579f64b2f32b965c520e2a4f36794cd3dece944d062454dc01df63", + "prototypes/connections/program.h": "2c3c955967883a9a8ea4566a006eccd44f3a173ac0682c2f272d09700f209639", + "prototypes/connections/server.c": "b905fa656fa626111ba765d6379aa03bd32fee97d19d48461cd752e75918ea31", + "prototypes/connections/check.py": "bf890ee92009fd57afcb6708ad325af360bf43a7c667c79c5dc90631889f5a3f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "8c0353ae6643351ba69505de4092aa40e34e5318f27d670de944cd875c4d51cc", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..110acab --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/Makefile @@ -0,0 +1,28 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build + +.PHONY: all check +all: $(BUILD)/pure-connections + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c store.c flow.c program.c store.h flow.h program.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c store.c flow.c program.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-program: test-program.c program.c program.h store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-program.c program.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-fields: test-fields.c program.c program.h store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-fields.c program.c $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/test-flow $(BUILD)/test-program $(BUILD)/test-fields $(BUILD)/pure-connections + $(BUILD)/test-flow + $(BUILD)/test-program + $(BUILD)/test-fields + python3 check.py --server $(BUILD)/pure-connections $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/check.py new file mode 100644 index 0000000..5c6a463 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/check.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +"""Exercise local TCP, immutable byte extents, and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import fcntl +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import struct +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, storage, budget=4 * 1024 * 1024, timeout=5000, program=None): + self.process = subprocess.Popen([str(binary), '--store', str(storage), '--port', '0', + '--program', str(program or ROOT / 'examples/storage.pure'), + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory): + storage = directory / 'objects' + checks, records = [], [] + with serving(binary, storage) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + with (storage / '.writer.lock').open('r+b') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + fcntl.lockf(lock, fcntl.LOCK_UN) + broken = storage / 'obj-broken' + broken.write_bytes(b'incomplete') + assert rpc(server, 'GET', 'broken')[0] == 500 + assert rpc(server, 'PUT', 'broken', b'failure', 0)[0] == 500 + assert broken.read_bytes() == b'incomplete' + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('writer contention and invalid extent fail without overwriting data') + + with serving(binary, storage) as second_writer: + with ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(rpc, target, 'PUT', 'race', value, 0) + for target, value in [(server, b'first'), (second_writer, b'second')]] + statuses = [task.result()[0] for task in tasks] + assert statuses.count(200) == 1 and all(status in [200, 412, 503] for status in statuses) + status, headers, body = rpc(server, 'GET', 'race') + assert status == 200 and headers['ETag'] == '"1"' and body in [b'first', b'second'] + records.append(second_writer.stats) + checks.append('two native writers cannot both create the same expected revision') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the immutable file whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock storage. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + wide = storage / 'obj-wide_revision' + raw = wide.read_bytes() + wide.write_bytes(raw[:8] + struct.pack('= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + prepared = authored_connections(binary, directory, storage, records, checks) + fields = field_connections(binary, directory, storage, records, checks) + return {'kind': 'prepared-connection-correctness-not-performance', 'checks': checks, + 'prepared_connections': prepared, + 'field_connections': fields, + 'server_runs': records, 'storage': server.ready['storage']} + + +def authored_connections(binary, directory, storage, records, checks): + get = 'on get { let o = store.read(request.key); reply(o, o.body); }' + put = 'on put { let s = store.write(request.key, request.revision, request.body); reply(s); }' + + def author(name, source): + path = directory / name + path.write_text(source) + return path + + def explain(path): + return json.loads(subprocess.check_output([str(binary), '--check-program', str(path)], text=True)) + + with serving(binary, storage) as writer: + assert rpc(writer, 'PUT', 'short_parent', bytes(range(48)), 0)[0] == 200 + assert rpc(writer, 'PUT', 'short_unused', bytes(range(80)), 0)[0] == 200 + records.append(writer.stats) + excerpt = ROOT / 'examples/excerpt.pure' + prepared = {'excerpt': explain(excerpt)} + assert prepared['excerpt']['read'] == { + 'to_end': False, 'offset': 32, 'length': 16, 'minimum_source_bytes': 80} + with serving(binary, storage, budget=16, program=excerpt) as selected: + status, headers, body = rpc(selected, 'GET', 'large') + assert status == 200 and body == bytes(range(32, 48)) and headers['ETag'] == '"3"' + # Final [32,48) fits, but the authored parent [16,80) does not. + assert rpc(selected, 'GET', 'short_parent')[0] == 413 + assert rpc(selected, 'GET', 'missing')[0] == 404 + assert selected.stats['peak_retained_payload_bytes'] == 16 + records.append(selected.stats) + checks.append('authored nested read maps only final demand while preserving parent bounds and revision') + + unused = author('unused.pure', 'on get { let o = store.read(request.key); ' + 'let unused = range(o.body, 0, 96); let body = range(o.body, 32, 16); reply(o, body); } ' + put) + with serving(binary, storage, budget=16, program=unused) as guarded: + assert rpc(guarded, 'GET', 'short_unused')[0] == 413 + assert rpc(guarded, 'GET', 'large')[2] == bytes(range(32, 48)) + records.append(guarded.stats) + checks.append('unused checked view still fails even when response range would be valid') + + select_write = author('write.pure', get + ' on put { let a = range(request.body, 2, 10); ' + 'let b = range(a, 3, 4); let alias = b; ' + 'let saved = store.write(request.key, request.revision, alias); reply(saved, b); }') + prepared['write'] = explain(select_write) + with serving(binary, storage, program=select_write) as writer: + status, headers, body = rpc(writer, 'PUT', 'selected', b'0123456789AB', 0) + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'GET', 'selected')[2] == b'5678' + assert rpc(writer, 'PUT', 'selected', b'012345678', 1)[0] == 413 + status, headers, body = rpc(writer, 'GET', 'selected') + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'PUT', 'selected', b'0123456789AB', 0)[0] == 412 + records.append(writer.stats) + checks.append('authored write and reply share selected input; invalid parent prevents the storage effect') + + metadata = author('metadata.pure', 'on get { let o = store.read(request.key); reply(o); } ' + put) + with serving(binary, storage, budget=1, program=metadata) as meta: + status, headers, body = rpc(meta, 'GET', 'large') + assert status == 200 and headers['ETag'] == '"3"' and body == b'' + assert rpc(meta, 'GET', 'absent')[0] == 404 + assert rpc(meta, 'GET', 'broken')[0] == 500 + assert meta.stats['peak_retained_payload_bytes'] == 0 + records.append(meta.stats) + checks.append('body-free response preserves read existence and malformed-object errors') + + echo = author('echo.pure', get + ' on put { let body = request.body; ' + 'let saved = store.write(request.key, request.revision, body); reply(saved, body); }') + payload = bytes(range(256)) * 4096 + with serving(binary, storage, budget=len(payload), program=echo) as echoed: + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo', len(payload), b'If-None-Match: *\r\n') + payload) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.read(1) == payload[:1] + assert rpc(echoed, 'GET', 'binary')[0] == 503 + assert response.read() == payload[1:] + response.close() + assert rpc(echoed, 'GET', 'binary')[2] == b'new' + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo_abort', len(payload), b'If-None-Match: *\r\n') + payload) + assert sock.recv(1) + assert rpc(echoed, 'GET', 'binary')[0] == 503 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + deadline = time.monotonic() + 3 + while rpc(echoed, 'GET', 'binary')[0] == 503: + assert time.monotonic() < deadline + time.sleep(0.01) + assert rpc(echoed, 'GET', 'echo_abort')[2] == payload + records.append(echoed.stats) + checks.append('input-backed response survives partial send and releases on finish or abort without undoing commit') + + bad = author('invalid.pure', get + get + put) + untouched = directory / 'must_not_exist' + rejected = subprocess.run([str(binary), '--program', str(bad), '--store', str(untouched)], + capture_output=True, text=True, timeout=10) + assert rejected.returncode == 2 and 'duplicate handler' in rejected.stderr and not untouched.exists() + checks.append('invalid authored program fails preparation before creating storage or accepting requests') + return prepared + + +def field_connections(binary, directory, storage, records, checks): + program = ROOT / 'examples/selection.pure' + plans = {'selection': json.loads(subprocess.check_output( + [str(binary), '--check-program', str(program)], text=True))} + assert plans['selection']['runtime_view_steps'] == 1 + assert plans['selection']['steps'][0] == { + 'source_step': 0, 'source': {'offset': 8, 'length': 0, 'to_end': True}, + 'offset': {'value': 0, 'from_u32le': True}, 'length': {'value': 4, 'from_u32le': True}} + document = '처음🙂 Pure는 필드를 연결한다.\n끝' + document_file = directory / 'document.txt' + document_file.write_bytes(document.encode('utf-8')) + with serving(binary, storage, program=program) as selected: + command = [sys.executable, str(ROOT / 'examples/select-text.py'), str(document_file), + '--key', 'text_selection', '--start', '2', '--end', '12', '--url', f'http://127.0.0.1:{selected.port}'] + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + assert result.returncode == 0, (result.stdout, result.stderr) + expected = document[2:12].encode('utf-8') + assert json.loads(result.stdout) == {'revision': '1', 'selected': document[2:12], 'stored_bytes': len(expected)} + assert rpc(selected, 'GET', 'text_selection')[2] == expected + checks.append('text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes') + + # Validation precedes the write: even a stale header cannot expose or + # commit partial input, and prior content/revision survive all failures. + invalid = [b'\0' * n for n in range(8)] + [ + struct.pack(' LIMIT - 8: + parser.error('document exceeds the prototype request limit') + text = document.decode('utf-8') + except (OSError, UnicodeError) as error: + parser.error(str(error)) + if not 0 <= args.start <= args.end <= len(text): + parser.error('selection is outside the document') + start = len(text[:args.start].encode('utf-8')) + selected = text[args.start:args.end].encode('utf-8') + # The boundary layout is explicit; storage receives only selected bytes. + payload = struct.pack(' 2**63 - 1: + parser.error('response has no valid stored revision (write may have occurred)') + except (OSError, http.client.HTTPException) as error: + parser.error(f'{error}; storage outcome is unknown if the request was sent') + finally: + connection.close() + print(json.dumps({'revision': revision[1:-1], 'selected': selected.decode('utf-8'), + 'stored_bytes': len(selected)}, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/selection.pure b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/selection.pure new file mode 100644 index 0000000..7d9d4ec --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/selection.pure @@ -0,0 +1,19 @@ +// This is an input boundary layout, not a required storage or RAM model. +// The client sends two little-endian u32 values followed by document bytes. +layout Selection { + start: u32le; + count: u32le; + document: bytes; +} + +on get { + let saved = store.read(request.key); + reply(saved, saved.body); +} + +on put { + let input = view(Selection, request.body); + let selected = range(input.document, input.start, input.count); + let saved = store.write(request.key, request.revision, selected); + reply(saved, selected); +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/storage.pure b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/storage.pure new file mode 100644 index 0000000..ff54ea3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/examples/storage.pure @@ -0,0 +1,11 @@ +// The host supplies request bytes, an object name, and the expected revision. +// Failed storage results propagate to the host's response without executing reply. +on get { + let object = store.read(request.key); + reply(object, object.body); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.c new file mode 100644 index 0000000..a01bb7c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.c @@ -0,0 +1,402 @@ +#include "program.h" +#include "store.h" +#include +#include +#include + +enum { NAME = 256, NUMBER, SYMBOLS = 32, LAYOUTS = 4, FIELDS = 16 }; +typedef enum { BYTE_VALUE, RESULT_VALUE, LAYOUT_VALUE, INDEX_VALUE } ValueKind; +typedef struct { + char name[32]; + ValueKind kind; + PureRegion region; + size_t step, layout; + PureIndex index; +} Symbol; +typedef struct { + char name[32]; + size_t offset, length; + bool number, tail; +} Field; +typedef struct { + char name[32]; + Field fields[FIELDS]; + size_t count, prefix; + bool tail; +} Layout; +typedef struct { + const char *source; + size_t length, at, line, token_line; + int token; + char name[32]; + size_t number; + char *error; + size_t error_size; + bool failed, put, effect; + Symbol symbols[SYMBOLS]; + size_t count, layout_count; + Layout layouts[LAYOUTS]; + PureProgram program; +} Parser; + +static bool reject(Parser *p, const char *message) { + if (!p->failed && p->error_size) snprintf(p->error, p->error_size, "line %zu: %s", p->token_line, message); + p->failed = true; + return false; +} + +static bool letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static bool next(Parser *p) { + while (p->at < p->length) { + char c = p->source[p->at]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (c == '\n') ++p->line; + ++p->at; + } else if (c == '/' && p->at + 1 < p->length && p->source[p->at + 1] == '/') { + while (p->at < p->length && p->source[p->at] != '\n') ++p->at; + } else break; + } + p->token_line = p->line; + p->name[0] = 0; + p->token = 0; + if (p->at == p->length) return true; + char c = p->source[p->at++]; + if (letter(c)) { + size_t start = p->at - 1; + while (p->at < p->length && (letter(p->source[p->at]) || + (p->source[p->at] >= '0' && p->source[p->at] <= '9'))) ++p->at; + size_t size = p->at - start; + if (size >= sizeof(p->name)) return reject(p, "name exceeds 31 bytes"); + memcpy(p->name, p->source + start, size); + p->name[size] = 0; + p->token = NAME; + } else if (c >= '0' && c <= '9') { + size_t value = (size_t)(c - '0'); + while (p->at < p->length && p->source[p->at] >= '0' && p->source[p->at] <= '9') { + unsigned digit = (unsigned)(p->source[p->at++] - '0'); + if (value > (SIZE_MAX - digit) / 10) return reject(p, "integer overflow"); + value = value * 10 + digit; + } + p->number = value; + p->token = NUMBER; + } else if (c && strchr("{}().,=;:[]", c)) p->token = (unsigned char)c; + else return reject(p, "unexpected character"); + return true; +} + +static bool is_word(Parser *p, const char *word) { + return p->token == NAME && !strcmp(p->name, word); +} + +static bool word(Parser *p, const char *expected) { + return is_word(p, expected) ? next(p) : reject(p, expected); +} + +static bool token(Parser *p, int expected) { + return p->token == expected ? next(p) : reject(p, "unexpected token"); +} + +static Symbol *find(Parser *p, const char *name) { + for (size_t i = 0; i < p->count; ++i) + if (!strcmp(name, p->symbols[i].name)) return &p->symbols[i]; + return NULL; +} + +static bool field(Parser *p, const char *object, const char *name) { + return word(p, object) && token(p, '.') && word(p, name); +} + +static bool require_bytes(Parser *p, const Symbol *value) { + return value->kind == BYTE_VALUE || reject(p, "expected bytes; select a byte field"); +} + +static void require_length(Parser *p, size_t required) { + size_t *minimum = p->put ? &p->program.input_required : &p->program.read_required; + if (required > *minimum) *minimum = required; +} + +static Layout *find_layout(Parser *p, const char *name) { + for (size_t i = 0; i < p->layout_count; ++i) + if (!strcmp(name, p->layouts[i].name)) return &p->layouts[i]; + return NULL; +} + +static bool layout_definition(Parser *p) { + if (p->layout_count == LAYOUTS) return reject(p, "program exceeds 4 layouts"); + if (!next(p)) return false; + if (p->token != NAME || find_layout(p, p->name)) return reject(p, "expected a unique layout name"); + Layout *layout = &p->layouts[p->layout_count]; + strcpy(layout->name, p->name); + if (!next(p) || !token(p, '{')) return false; + while (p->token != '}') { + if (layout->count == FIELDS) return reject(p, "layout exceeds 16 fields"); + if (layout->tail) return reject(p, "variable bytes must be the last field"); + if (p->token != NAME) return reject(p, "expected a field name"); + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(layout->fields[i].name, p->name)) return reject(p, "duplicate field"); + Field *f = &layout->fields[layout->count]; + strcpy(f->name, p->name); + f->offset = layout->prefix; + if (!next(p) || !token(p, ':')) return false; + if (is_word(p, "u32le")) { + f->number = true; + f->length = 4; + if (!next(p)) return false; + } else if (is_word(p, "bytes")) { + if (!next(p)) return false; + if (p->token == '[') { + if (!next(p)) return false; + if (p->token != NUMBER) return reject(p, "fixed bytes requires a constant size"); + f->length = p->number; + if (!next(p) || !token(p, ']')) return false; + } else { f->tail = true; layout->tail = true; } + } else return reject(p, "supported field types: u32le, bytes[N], bytes"); + if (f->length > PURE_OBJECT_LIMIT - layout->prefix) return reject(p, "layout exceeds object bound"); + layout->prefix += f->length; + ++layout->count; + if (!token(p, ';')) return false; + } + if (!layout->count) return reject(p, "empty layout"); + ++p->layout_count; + return token(p, '}'); +} + +static bool reference(Parser *p, Symbol *out) { + if (is_word(p, "request")) { + if (!p->put) return reject(p, "request.body is only available in on put"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return field(p, "request", "body"); + } + Symbol *symbol = p->token == NAME ? find(p, p->name) : NULL; + if (!symbol) return reject(p, "unknown value"); + *out = *symbol; + if (!next(p)) return false; + if (p->token != '.') return true; + if (!next(p)) return false; + if (symbol->kind == RESULT_VALUE) { + if (p->put) return reject(p, "write result has no body; use the input value"); + *out = (Symbol){.kind = BYTE_VALUE, .region = {.to_end = true}}; + return word(p, "body"); + } + if (symbol->kind != LAYOUT_VALUE || p->token != NAME) return reject(p, "expected a layout field"); + Layout *layout = &p->layouts[symbol->layout]; + Field *f = NULL; + for (size_t i = 0; i < layout->count; ++i) + if (!strcmp(p->name, layout->fields[i].name)) f = &layout->fields[i]; + if (!f) return reject(p, "unknown layout field"); + size_t offset = symbol->region.offset + f->offset; + *out = (Symbol){.kind = f->number ? INDEX_VALUE : BYTE_VALUE}; + if (f->number) out->index = (PureIndex){.value = offset, .from_u32le = true}; + else out->region = (PureRegion){.offset = offset, + .length = f->tail ? (symbol->region.to_end ? 0 : symbol->region.length - f->offset) : f->length, + .to_end = f->tail && symbol->region.to_end}; + return next(p); +} + +static bool byte_reference(Parser *p, Symbol *out) { + return reference(p, out) && require_bytes(p, out); +} + +static bool index_value(Parser *p, PureIndex *out) { + if (p->token == NUMBER) { + *out = (PureIndex){.value = p->number}; + return next(p); + } + Symbol value; + if (!reference(p, &value)) return false; + if (value.kind != INDEX_VALUE) return reject(p, "range requires an integer or u32le field"); + *out = value.index; + return true; +} + +static bool layout_view(Parser *p, Symbol *out) { + if (!next(p) || !token(p, '(')) return false; + Layout *layout = p->token == NAME ? find_layout(p, p->name) : NULL; + if (!layout) return reject(p, "unknown layout"); + if (!next(p) || !token(p, ',')) return false; + Symbol source; + if (!byte_reference(p, &source) || !token(p, ')')) return false; + if (source.step) return reject(p, "layout view requires a statically located source"); + size_t bound = source.region.to_end ? PURE_OBJECT_LIMIT - source.region.offset : source.region.length; + if (layout->prefix > bound) return reject(p, "layout exceeds source bound"); + require_length(p, source.region.offset + layout->prefix); + *out = (Symbol){.kind = LAYOUT_VALUE, .region = source.region, .layout = (size_t)(layout - p->layouts)}; + if (!layout->tail) { out->region.length = layout->prefix; out->region.to_end = false; } + return true; +} + +static bool view(Parser *p, Symbol *out) { + if (is_word(p, "view")) return layout_view(p, out); + if (!is_word(p, "range")) return reference(p, out); + Symbol parent; + PureIndex offset, length; + if (!next(p) || !token(p, '(') || !byte_reference(p, &parent) || !token(p, ',') || + !index_value(p, &offset) || !token(p, ',') || !index_value(p, &length) || !token(p, ')')) return false; + bool dynamic = parent.step || offset.from_u32le || length.from_u32le; + *out = (Symbol){.kind = BYTE_VALUE}; + if (dynamic) { + if (!p->put) return reject(p, "data-dependent ranges currently require PUT input"); + if (p->program.input_steps == PURE_DYNAMIC_LIMIT) return reject(p, "input exceeds 8 dynamic ranges"); + if ((!offset.from_u32le && offset.value > PURE_OBJECT_LIMIT) || + (!length.from_u32le && length.value > PURE_OBJECT_LIMIT)) return reject(p, "range exceeds object bound"); + PureRangeStep *step = &p->program.steps[p->program.input_steps++]; + *step = (PureRangeStep){.source = parent.region, .source_step = parent.step, + .offset = offset, .length = length}; + out->step = p->program.input_steps; + } else { + size_t bound = parent.region.to_end ? PURE_OBJECT_LIMIT - parent.region.offset : parent.region.length; + if (offset.value > bound || length.value > bound - offset.value) return reject(p, "range exceeds its source bound"); + out->region = (PureRegion){.offset = parent.region.offset + offset.value, .length = length.value}; + require_length(p, out->region.offset + out->region.length); + } + ++p->program.range_definitions; + return true; +} + +static bool effect(Parser *p, Symbol *out) { + if (p->effect) return reject(p, "only one storage effect is supported per handler"); + if (!word(p, "store") || !token(p, '.') || !word(p, p->put ? "write" : "read") || + !token(p, '(') || !field(p, "request", "key")) return false; + if (p->put) { + Symbol value; + if (!token(p, ',') || !field(p, "request", "revision") || !token(p, ',') || + !byte_reference(p, &value)) return false; + p->program.write = value.region; + p->program.write_step = value.step; + } + if (!token(p, ')')) return false; + out->kind = RESULT_VALUE; + p->effect = true; + return true; +} + +static bool handler(Parser *p) { + p->count = 0; + p->effect = false; + if (!token(p, '{')) return false; + while (is_word(p, "let")) { + if (p->put && p->effect) return reject(p, "all views must precede store.write; reply must follow it"); + if (p->count == SYMBOLS) return reject(p, "handler exceeds 32 definitions"); + if (!next(p)) return false; + if (p->token != NAME || is_word(p, "request") || is_word(p, "store") || + is_word(p, "range") || is_word(p, "view")) return reject(p, "expected a non-reserved definition name"); + if (find(p, p->name)) return reject(p, "duplicate definition"); + char name[32]; + strcpy(name, p->name); + Symbol symbol = {0}; + if (!next(p) || !token(p, '=')) return false; + if (is_word(p, "store")) { + if (!effect(p, &symbol)) return false; + } else { + if (!view(p, &symbol)) return false; + ++p->program.view_definitions; + } + if (!token(p, ';')) return false; + strcpy(symbol.name, name); + p->symbols[p->count++] = symbol; + } + if (!p->effect) return reject(p, "handler requires its storage effect"); + if (!word(p, "reply") || !token(p, '(')) return false; + Symbol *result = p->token == NAME ? find(p, p->name) : NULL; + if (!result || result->kind != RESULT_VALUE) return reject(p, "reply requires this handler's storage result"); + if (!next(p)) return false; + Symbol body = {0}; + bool has_body = p->token == ','; + if (has_body && (!next(p) || !byte_reference(p, &body))) return false; + if (!token(p, ')') || !token(p, ';') || !token(p, '}')) return false; + if (p->put) { + p->program.reply = body.region; + p->program.reply_step = body.step; + p->program.reply_input = has_body; + } else p->program.read = body.region; + return true; +} + +bool pure_program_parse(const char *source, size_t length, PureProgram *out, + char *error, size_t error_size) { + Parser p = {.source = source, .length = length, .line = 1, .token_line = 1, + .error = error, .error_size = error_size}; + if (error_size) error[0] = 0; + if (length > PURE_PROGRAM_LIMIT) return reject(&p, "program exceeds 16 KiB"); + if (!next(&p)) return false; + while (is_word(&p, "layout")) if (!layout_definition(&p)) return false; + unsigned handlers = 0; + while (p.token) { + if (!word(&p, "on")) return false; + if (!is_word(&p, "get") && !is_word(&p, "put")) return reject(&p, "expected get or put handler"); + p.put = is_word(&p, "put"); + unsigned bit = p.put ? 2 : 1; + if (handlers & bit) return reject(&p, "duplicate handler"); + handlers |= bit; + if (!next(&p) || !handler(&p)) return false; + } + if (handlers != 3) return reject(&p, "both get and put handlers are required"); + *out = p.program; /* Invalid preparation never changes the installed plan. */ + return true; +} + +bool pure_program_load(const char *path, PureProgram *out, char *error, size_t error_size) { + FILE *file = fopen(path, "rb"); + if (!file) { + if (error_size) snprintf(error, error_size, "cannot open program"); + return false; + } + char source[PURE_PROGRAM_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool bad = ferror(file) != 0; + if (fclose(file)) bad = true; + if (bad) { + if (error_size) snprintf(error, error_size, "cannot read program"); + return false; + } + return pure_program_parse(source, length, out, error, error_size); +} + +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; +} + +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/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.h b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.h new file mode 100644 index 0000000..f2b5b2f --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/program.h @@ -0,0 +1,39 @@ +#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; +} 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); +#endif diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/server.c new file mode 100644 index 0000000..e4e5b88 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/server.c @@ -0,0 +1,393 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include "program.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received, charged; + unsigned char *body; + bool put; + int64_t expected; + PureStoredView stored; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + PureProgram program; + size_t budget, retained, peak_retained, active, peak_active; + uint64_t accepted, commits, gets, conflicts, budget_rejections, expired; + uint64_t releases, prefix_copy_bytes, borrowed_output_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + bool owned = c->body || c->stored.mapped_size || c->charged; + pure_store_release(&c->stored); + free(c->body); + c->body = NULL; + assert(c->charged <= c->server->retained); + c->server->retained -= c->charged; + c->charged = 0; + if (owned) ++c->server->releases; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else if (c->body || c->stored.mapped_size || c->charged) release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static bool charge(Connection *c, size_t size) { + Server *s = c->server; + if (size > s->budget - s->retained) { ++s->budget_rejections; return false; } + s->retained += size; + c->charged += size; + if (s->retained > s->peak_retained) s->peak_retained = s->retained; + return true; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + if (c->body || c->stored.mapped_size || c->charged) release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void run_request(Connection *c) { + PureResult result; + int64_t revision = 0; + PureProgram *program = &c->server->program; + PureSpan output = {0}; + if (c->put) { + PureSpan source = {c->body, c->body_size}, value; + /* Validate every authored view before performing an external effect, + * including unused views. Static preparation preserves these guards. */ + if (!pure_program_input(program, source, &value, &output)) { + fail(c, 413, "Content Too Large", "limit"); + return; + } + result = pure_store_put(c->server->path, c->key, c->expected, value, &revision); + /* A reply can consume the same input. Its owner lives until transfer + * completion; otherwise storage completion ends the input lifetime. */ + if (result != PURE_OK || !output.size) { release_payload(c); output = (PureSpan){0}; } + if (result == PURE_OK) { + ++c->server->commits; + c->server->borrowed_output_bytes += output.size; + } + } else { + result = pure_store_select(c->server->path, c->key, program->read.offset, + program->read.length, program->read.to_end, program->read_required, + c->server->budget - c->server->retained, &c->stored); + if (result == PURE_OK) { + if (!charge(c, c->stored.body.size)) abort(); /* Same owner thread. */ + revision = c->stored.revision; + output = c->stored.body; + ++c->server->gets; + c->server->borrowed_output_bytes += c->stored.body.size; + } else if (result == PURE_BUDGET) ++c->server->budget_rejections; + } + switch (result) { + case PURE_OK: respond(c, 200, "OK", "none", revision, output); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + ++c->server->conflicts; + respond(c, 412, "Precondition Failed", "conflict", revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "store_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + case PURE_UNCERTAIN: fail(c, 500, "Internal Server Error", "durability_unknown"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + if (!c->put) { run_request(c); return; } + if (!charge(c, c->body_size)) { fail(c, 503, "Service Unavailable", "payload_budget"); return; } + c->body = malloc(c->body_size ? c->body_size : 1); + if (!c->body) { fail(c, 503, "Service Unavailable", "allocation"); return; } + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + const char *program_path = NULL; + bool explain = argc == 3 && !strcmp(argv[1], "--check-program"); + char program_error[160]; + if (explain) program_path = argv[2]; + for (int i = 1; !explain && i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--store")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--program")) program_path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!program_path || (!explain && !server.path)) goto usage; + if (!pure_program_load(program_path, &server.program, program_error, sizeof(program_error))) { + fprintf(stderr, "Program: %s\n", program_error); return 2; + } + if (explain) { + PureProgram *p = &server.program; + printf("{\"read\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"write\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"reply_input\":%s,\"view_definitions\":%zu,\"range_definitions\":%zu," + "\"runtime_view_steps\":%zu,\"write_step\":%zu,\"reply_step\":%zu,\"prepared_program_bytes\":%zu,\"steps\":[", + p->read.to_end ? "true" : "false", p->read.offset, p->read.length, p->read_required, + p->write.to_end ? "true" : "false", p->write.offset, p->write.length, p->input_required, + p->reply_input ? "true" : "false", p->view_definitions, p->range_definitions, + p->input_steps, p->write_step, p->reply_step, sizeof(*p)); + for (size_t i = 0; i < p->input_steps; ++i) { + const PureRangeStep *step = &p->steps[i]; + printf("%s{\"source_step\":%zu,\"source\":{\"offset\":%zu,\"length\":%zu,\"to_end\":%s}," + "\"offset\":{\"value\":%zu,\"from_u32le\":%s},\"length\":{\"value\":%zu,\"from_u32le\":%s}}", + i ? "," : "", step->source_step, step->source.offset, step->source.length, + step->source.to_end ? "true" : "false", step->offset.value, step->offset.from_u32le ? "true" : "false", + step->length.value, step->length.from_u32le ? "true" : "false"); + } + puts("]}"); + return 0; + } + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize byte store directory\n", stderr); return 1; } + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"storage\":\"immutable-byte-extents-v1\"}\n", ntohs(address.sin_port)); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.retained, server.peak_retained, server.budget, server.peak_active, + server.accepted, server.commits, server.gets, server.conflicts, server.budget_rejections, + server.expired, server.releases, server.prefix_copy_bytes, server.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --program FILE --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n" + "pure-connections --check-program FILE\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.c new file mode 100644 index 0000000..1c13a80 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.c @@ -0,0 +1,228 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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); +} + +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, 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; + 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; + } + fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (fd >= 0) { + size_t size; + if (!read_header(fd, revision, &size)) goto finish; + close(fd); + fd = -1; + } else if (errno != ENOENT) goto finish; + if (*revision != expected) { result = PURE_CONFLICT; goto finish; } + if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + 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, body.size); + if (!write_bytes(fd, header, sizeof(header)) || fault(PURE_AFTER_HEADER) || + !write_bytes(fd, body.data, body.size) || 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 (*temporary) unlinkat(directory, temporary, 0); + if (lock >= 0) close(lock); + close(directory); + return result; +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.h new file mode 100644 index 0000000..c0b2564 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/store.h @@ -0,0 +1,40 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include + +#define PURE_OBJECT_LIMIT ((2u * 6u + 32u) * 32768u + 8192u) +typedef enum { + PURE_OK, PURE_NOT_FOUND, PURE_CONFLICT, PURE_BUSY, PURE_BUDGET, + PURE_LIMIT, PURE_STORE_ERROR, PURE_UNCERTAIN +} PureResult; +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); + +/* 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/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-fields.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-fields.c new file mode 100644 index 0000000..63dfc3e --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-fields.c @@ -0,0 +1,153 @@ +#include "program.h" +#include +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static const char layout[] = "layout Envelope { marker: bytes[1]; start: u32le; count: u32le; text: bytes; }"; +static PureProgram parse(const char *source) { + PureProgram p; + char error[160]; + if (!pure_program_parse(source, strlen(source), &p, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return p; +} +static void invalid(const char *source) { + PureProgram p; + memset(&p, 0x5a, sizeof(p)); + unsigned char before[sizeof(p)]; + memcpy(before, &p, sizeof(p)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(*error && !memcmp(before, &p, sizeof(p))); +} +static void encode(unsigned char *at, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) at[i] = (unsigned char)(value >> (8 * i)); +} +int main(void) { + char source[8000]; + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let alias = input; let start = alias.start; let text = alias.text; " + "let selected = range(text, start, alias.count); let result = selected; " + "let saved = store.write(request.key, request.revision, result); reply(saved, selected); }", layout, get); + PureProgram p = parse(source); + CHECK(p.input_steps == 1 && p.input_required == 9 && p.write_step == 1 && p.reply_step == 1); + CHECK(p.steps[0].source.offset == 9 && p.steps[0].source.to_end); + CHECK(p.steps[0].offset.value == 1 && p.steps[0].offset.from_u32le); + CHECK(p.steps[0].length.value == 5 && p.steps[0].length.from_u32le); + unsigned char bytes[609]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 13); + PureSpan stored, returned; + for (size_t n = 0; n < 9; ++n) { + CHECK(!pure_program_input(&p, (PureSpan){bytes, n}, &stored, &returned)); + CHECK(!stored.size && !returned.size); + } + const uint32_t offsets[] = {0, 1, 255, 257, 599, 600, 601, UINT32_MAX}; + const uint32_t counts[] = {0, 1, 2, 260, 600, UINT32_MAX}; + size_t comparisons = 0; + for (size_t i = 0; i < sizeof(offsets) / sizeof(*offsets); ++i) + for (size_t j = 0; j < sizeof(counts) / sizeof(*counts); ++j) { + uint32_t start = offsets[i], count = counts[j]; + encode(bytes + 1, start); + encode(bytes + 5, count); + bool expected = start <= 600 && count <= 600 - start; + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned) == expected); + if (expected) { + CHECK(stored.size == count && returned.size == count && stored.data == returned.data); + CHECK(!count || stored.data == bytes + 9 + start); + } else CHECK(!stored.size && !returned.size); + ++comparisons; + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let first = range(input.text, input.start, input.count); let second = range(first, 1, 2); " + "let saved = store.write(request.key, request.revision, second); reply(saved, second); }", layout, get); + p = parse(source); + CHECK(p.input_steps == 2 && p.steps[1].source_step == 1); + encode(bytes + 1, 3); encode(bytes + 5, 4); + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + CHECK(stored.size == 2 && stored.data == bytes + 13); + encode(bytes + 5, 2); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let unused = range(input.text, input.start, input.count); " + "let saved = store.write(request.key, request.revision, input.text); reply(saved); }", layout, get); + p = parse(source); + encode(bytes + 1, UINT32_MAX); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); " + "let input = view(Envelope, o.body); reply(o, input.text); } %s", layout, put); + p = parse(source); + CHECK(p.read.to_end && p.read.offset == 9 && p.read_required == 9 && !p.input_steps); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let selection = range(input.text, 2, 3); let saved = store.write(request.key, request.revision, selection); reply(saved); }", layout, get); + p = parse(source); + CHECK(!p.input_steps && p.write.offset == 11 && p.write.length == 3 && p.input_required == 14); + snprintf(source, sizeof(source), "layout Empty { body: bytes; } %s on put { let input = view(Empty, request.body); " + "let saved = store.write(request.key, request.revision, input.body); reply(saved, input.body); }", get); + p = parse(source); + CHECK(pure_program_input(&p, (PureSpan){0}, &stored, &returned) && !stored.size && !returned.size); + + const char *bad_layout[] = { + "layout E {}", "layout E { x: bytes; y: u32le; }", "layout E { x: u32le; x: bytes; }", + "layout E { x: bytes[1449985]; }", "layout E { x: bytes[1449984]; y: u32le; }", + "layout E { x: bytes[184467440737095516160]; }", "layout E { x: u64le; }", + "layout E { x: u32le; } layout E { y: bytes; }" + }; + for (size_t i = 0; i < sizeof(bad_layout) / sizeof(*bad_layout); ++i) { + snprintf(source, sizeof(source), "%s %s %s", bad_layout[i], get, put); invalid(source); + } + const char *bad_input[] = { + "let input = view(Missing, request.body);", + "let input = view(Envelope, request.body); let x = input.missing;", + "let input = view(Envelope, request.body); let x = range(input.text, input.text, 1);", + "let short = range(request.body, 0, 8); let input = view(Envelope, short);", + "let input = view(Envelope, request.body); let x = range(input.text, input.start, 20); let y = view(Envelope, x);" + }; + for (size_t i = 0; i < sizeof(bad_input) / sizeof(*bad_input); ++i) { + snprintf(source, sizeof(source), "%s %s on put { %s let saved = store.write(request.key, request.revision, request.body); reply(saved); }", layout, get, bad_input[i]); + invalid(source); + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let saved = store.write(request.key, request.revision, input.start); reply(saved); }", layout, get); + invalid(source); + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); let input = view(Envelope, o.body); " + "let x = range(input.text, input.start, input.count); reply(o, x); } %s", layout, put); + invalid(source); + for (size_t count = 8; count <= 9; ++count) { + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); ", layout, get); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let v%zu = range(input.text, input.start, input.count); ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let saved = store.write(request.key, request.revision, input.text); reply(saved); }"); + if (count == 8) CHECK(parse(source).input_steps == 8); else invalid(source); + } + for (size_t count = 4; count <= 5; ++count) { + source[0] = 0; + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "layout L%zu { body: bytes; } ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "%s %s", get, put); + if (count == 4) (void)parse(source); else invalid(source); + } + for (size_t count = 16; count <= 17; ++count) { + strcpy(source, "layout L { "); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "f%zu: u32le; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "} %s %s", get, put); + if (count == 16) (void)parse(source); else invalid(source); + } + printf("{\"fields\":\"named-layouts-unaligned-u32le-tail-dynamic-ranges-aliases-failures-limits\"," + "\"data_boundary_cases\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..5ff028d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-flow.c @@ -0,0 +1,183 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + long page = sysconf(_SC_PAGESIZE); + CHECK(page >= 128 && (size_t)page <= PURE_OBJECT_LIMIT / 3); + size_t size = (size_t)page * 3, offset = (size_t)page + 13, length = (size_t)page + 105; + unsigned char *bytes = malloc(size); + CHECK(bytes); + for (size_t i = 0; i < size; ++i) bytes[i] = (unsigned char)((i * 17u) ^ (i >> 8) ^ (i >> 16)); + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, size}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", offset, length, length, &first) == PURE_OK); + CHECK(first.body.size == length && !memcmp(first.body.data, bytes + offset, length)); + pure_store_release(&first); + free(bytes); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-program.c b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-program.c new file mode 100644 index 0000000..6a4a19d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/connections/test-program.c @@ -0,0 +1,111 @@ +#include "program.h" +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static PureProgram parse(const char *source) { + PureProgram program; + char error[160]; + if (!pure_program_parse(source, strlen(source), &program, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return program; +} +static void invalid(const char *source) { + PureProgram program; + memset(&program, 0x5a, sizeof(program)); + unsigned char original[sizeof(program)]; + memcpy(original, &program, sizeof(program)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &program, error, sizeof(error))); + CHECK(*error && !memcmp(original, &program, sizeof(program))); +} +int main(void) { + char source[4096]; + snprintf(source, sizeof(source), "%s %s", get, put); + PureProgram plan = parse(source); + CHECK(plan.read.to_end && plan.write.to_end && !plan.reply_input); + CHECK(!plan.view_definitions && !plan.read_required && !plan.input_required); + snprintf(source, sizeof(source), "%s on put { let a = range(request.body, 2, 10); " + "let b = range(a, 3, 4); let alias = b; " + "let saved = store.write(request.key, request.revision, alias); reply(saved, b); }", get); + plan = parse(source); + CHECK(plan.write.offset == 5 && plan.write.length == 4 && !plan.write.to_end); + CHECK(plan.reply.offset == 5 && plan.reply.length == 4 && plan.reply_input); + CHECK(plan.input_required == 12 && plan.range_definitions == 2 && plan.view_definitions == 3); + /* Ordered reference views keep even unused bounds failures observable. */ + unsigned char bytes[160]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 7); + size_t comparisons = 0; + for (size_t a = 0; a < 4; ++a) for (size_t b = 0; b < 6; ++b) + for (size_t c = 0; c <= b; ++c) for (size_t d = 0; d <= b - c; ++d) { + size_t unused_end = (a + b + c) % 12; + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); " + "let unused = range(o.body, %zu, 0); let first = range(o.body, %zu, %zu); " + "let second = range(first, %zu, %zu); reply(o, second); } %s", + unused_end, a, b, c, d, put); + plan = parse(source); + for (size_t n = 0; n < 16; ++n) { + bool reference_ok = unused_end <= n && a <= n && b <= n - a; + PureSpan selected; + bool prepared_ok = n >= plan.read_required && + pure_region_span(plan.read, (PureSpan){bytes, n}, &selected); + CHECK(reference_ok == prepared_ok); + if (reference_ok) { + CHECK(selected.size == d); + CHECK(!d || !memcmp(selected.data, bytes + a + c, d)); + } + ++comparisons; + } + } + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); reply(o); } %s", put); + plan = parse(source); + CHECK(!plan.read.to_end && !plan.read.length); + const char *bad_get[] = { + "on get { let o = store.read(request.key); let x = range(o.body, 0, 2); let y = range(x, 2, 1); reply(o, y); }", + "on get { let o = store.read(request.key); let x = range(o.body, 184467440737095516160, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let x = range(o.body, 1449984, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let o = o.body; reply(o); }", + "on get { let o = store.read(request.key); let x = x; reply(o, x); }", + "on get { let o = store.read(request.key); let x = store.read(request.key); reply(x); }", + "on get { let o = store.read(request.key); reply(o, request.body); }", + "on get { let o = store.read(request.key); let x = o.body; reply(x); }", + "on get { let o = store.read(request.key); reply(o.body); }", + "on get { let o = store.read(request.key); reply(o); let x = o.body; }", + "on get { let o = store.read(request.key); reply(o);", "on get {}" + }; + for (size_t i = 0; i < sizeof(bad_get) / sizeof(*bad_get); ++i) { + snprintf(source, sizeof(source), "%s %s", bad_get[i], put); invalid(source); + } + const char *bad_put[] = { + "on put { let s = store.write(request.key, request.revision, request.body); let b = range(request.body, 0, 1); reply(s, b); }", + "on put { let s = store.write(request.key, request.revision, request.body); reply(s, s.body); }", + "on put { let s = store.read(request.key); reply(s); }" + }; + for (size_t i = 0; i < sizeof(bad_put) / sizeof(*bad_put); ++i) { + snprintf(source, sizeof(source), "%s %s", get, bad_put[i]); invalid(source); + } + invalid(get); + snprintf(source, sizeof(source), "%s %s %s", get, get, put); invalid(source); + snprintf(source, sizeof(source), "%s %s garbage", get, put); invalid(source); + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); "); + for (size_t i = 0; i < 32; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let x%zu = o.body; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "reply(o); } %s", put); invalid(source); + char oversized[PURE_PROGRAM_LIMIT + 1]; + memset(oversized, ' ', sizeof(oversized)); + char error[160]; + CHECK(!pure_program_parse(oversized, sizeof(oversized), &plan, error, sizeof(error))); + const char embedded[] = "on get\0{}"; + CHECK(!pure_program_parse(embedded, sizeof(embedded) - 1, &plan, error, sizeof(error))); + PureSpan selected; + CHECK(pure_region_span((PureRegion){0}, (PureSpan){0}, &selected) && !selected.data && !selected.size); + printf("{\"program\":\"prepare-views-preserve-guards-types-effects-and-atomic-install\"," + "\"boundary_semantic_comparisons\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/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/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/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/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/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/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/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/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/core.wasm new file mode 100644 index 0000000..79c50a8 Binary files /dev/null and b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/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/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-field-connections/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/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/connections/results/2026-09-23-field-connections/verification.json b/prototypes/connections/results/2026-09-23-field-connections/verification.json new file mode 100644 index 0000000..4befb2d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-field-connections/verification.json @@ -0,0 +1,62 @@ +{ + "date": "2026-09-23", + "scope": "Named boundary fields and data-dependent byte selection; correctness and source lifetimes, not a performance comparison", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "os_page_size_bytes": 16384, + "cwd": "prototypes/connections", + "native_command": "make check EVIDENCE=results/2026-09-23-field-connections/native.json", + "sanitized_command": "make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-field-connections/sanitized.json", + "core_checks": { + "test-flow": { + "native": "passed", + "asan_ubsan": "passed" + }, + "test-program": { + "native": "passed", + "asan_ubsan": "passed", + "boundary_semantic_comparisons": 3584 + }, + "test-fields": { + "native": "passed", + "asan_ubsan": "passed", + "data_boundary_cases": 48 + }, + "leak_detection_disabled": false + }, + "unit_binary_sha256": { + "build/test-flow": "81bb00a859ffc6dad647159514e80d97ff2897687f6e904610fdd3b70ea30878", + "build/test-program": "c15f0f40c50a10b57d456e064d5b64d5a4cbef29bda55495fda0b4648105a866", + "build/test-fields": "8b274b9fe8b8c45198f8d536186aeefe0aecd8564f56d903cc4a634624889e00", + "build/sanitized/test-flow": "bff35676c65fcb9cd5cf3ee2daa1da33067c6648e436ad62d72ada4609542f93", + "build/sanitized/test-program": "e6487158d71ef075771965b4d80c7c116c689174a7cd2dee4f9e5a465def4325", + "build/sanitized/test-fields": "30852cde86252c52ff244f544b847ef05683ab0b33ed4dd4a134fe5fdd49a940" + }, + "integration_scenarios_each": 28, + "previous_scenarios": 22, + "field_scenarios": 6, + "source_snapshots": "sources/", + "source_hashes_equal": true, + "source_snapshots_match_records": true, + "final_binaries_match_records": true, + "prepared_connections_equal": true, + "field_connections_equal": true, + "prepared_program_bytes": 648, + "previous_prepared_program_bytes": 112, + "native_dynamic_dependencies_ldd": [ + "\tlinux-vdso.so.1 (0x00007fff973d8000)", + "\tlibc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x00007fff97190000)", + "\t/lib/ld-linux-aarch64.so.1 (0x00007fff973a0000)" + ], + "limitations": [ + "Declared boundary layouts are not a universal storage schema or application object model", + "Only u32le, fixed bytes, and a final variable byte field; no general arithmetic or schema version negotiation", + "Dynamic ranges operate on PUT input; GET numeric-dependent ranges are rejected", + "One storage effect per handler, no read-modify-write, branches or feedback loops", + "Program always reserves room for eight dynamic range steps, including programs that use none", + "Source receive allocation remains fully retained/charged even when a small selection is returned", + "Server byte fields do not validate UTF-8; the example Python client preserves code-point boundaries", + "Logical payload bounds exclude mapping pages, page cache, disk, OS sockets and client allocations", + "Synchronous storage can delay other connections and timeout enforcement", + "Linux AArch64 only; macOS, real power loss, and large-service performance not tested" + ] +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/README.md b/prototypes/connections/results/2026-09-23-native-connections/README.md new file mode 100644 index 0000000..0e4f385 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/README.md @@ -0,0 +1,26 @@ +# 018 검증 자료 + +> 이 SQLite 구현은 [019](../2026-09-23-byte-storage/README.md)의 SQL 없는 경로로 교체했다. +> 이 디렉터리의 원자료와 소스 사본은 당시 결과를 보존한다. 현재 작업 트리에서 실행하면 +> 새 구현을 검사하므로, 당시 구현의 재현에는 `sources/`의 파일들을 별도 작업 트리에 사용한다. + +이 디렉터리는 실제 루프백 TCP·SQLite·기존 Workbench CLI를 연결한 기능·수명 검사 자료다. +속도·처리량·전체 RSS 비교는 수행하지 않았다. + +- [native.json](native.json): 일반 빌드의 15개 통합 시나리오, 종료 시 자원 수, 환경과 소스·실행 파일 해시. +- [sanitized.json](sanitized.json): ASan·UBSan 빌드에서 같은 시나리오. 누수 검사를 비활성화하지 않았다. +- [verification.json](verification.json): 빌드·실행 명령, 코어·CLI 결과와 파일 일치 확인. + +서버가 만든 임시 DB에 실제 작성물 배치를 저장하고 새 CLI에서 복원했다. 객체 버전의 +충돌과 64비트 정밀도, 저장 오류, DB 잠금, 읽는 중 다른 커밋, 부분 입력·출력, 연결 종료, +한도와 기한을 검사한다. 큰 본문은 부분 전송·한도 거절을 발생시키기 위한 정확성 fixture다. +작업량을 크게 만들어 성능을 주장하는 벤치마크가 아니다. + +`retained_payload_bytes`는 수신 할당과 빌린 DB 결과의 길이 합이다. 실제 SQLite·OS·전체 +프로세스 메모리는 포함하지 않는다. `ingress_prefix_copy_bytes`는 서버가 직접 복사한 +수신 접두부만 센다. 전송 배치에 따라 값이 바뀔 수 있어 두 빌드의 시간·복사 효율 비교에 쓰지 않는다. + +재현은 [구현 README](../../README.md)의 `make check` 명령을 따른다. `EVIDENCE=/새/경로.json`을 +추가하면 새 증거 파일을 저장한다. 이미 있는 파일을 덮어쓰지 않는다. 실제 소켓·자식 프로세스· +LeakSanitizer가 제한된 환경에서는 권한이 필요하다. 이 실행에서는 샌드박스가 해당 기능을 +차단해 승인된 샌드박스 밖의 로컬 검사로 확인했다. 외부 네트워크 서비스는 사용하지 않았다. diff --git a/prototypes/connections/results/2026-09-23-native-connections/native.json b/prototypes/connections/results/2026-09-23-native-connections/native.json new file mode 100644 index 0000000..5f7e53f --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/native.json @@ -0,0 +1,98 @@ +{ + "kind": "native-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "busy and injected DB failure do not acknowledge or change data", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 38, + "commits": 11, + "gets": 13, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115411, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39112 + } + ], + "sqlite_runtime": "3.46.1", + "binary_sha256": "ff1c2d09d940d9f9a9170e6fea894aee9926e98f04567d389a0c00d5ddf4c7f7", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b0b50b0d85900b26257aac68177951e8319fd5482a1fcff4c91722f7ba30f74c", + "prototypes/connections/store.h": "07d347ae872a3d618dda1697b01bb32abee44b3451472a1b2225963085c11cf2", + "prototypes/connections/server.c": "3ff9d9497ad2a8ece657b50af9f8e6e564a974e6bbe3542f0ebe32279883d219", + "prototypes/connections/check.py": "be75db13ab2f11d3dd0e064aaa271bf28145aef11423e8ed536c755dcf96eaac", + "prototypes/connections/test-flow.c": "17472b90ebd3067b43a0cd06fb571111206b2025a2bd552b1602c3189f17ce0b", + "prototypes/connections/Makefile": "6c05917782adf733d31d9bfd42ee8f41b05fe94d965c362577189d6849b61e3e", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sanitized.json b/prototypes/connections/results/2026-09-23-native-connections/sanitized.json new file mode 100644 index 0000000..c391070 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sanitized.json @@ -0,0 +1,98 @@ +{ + "kind": "native-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "busy and injected DB failure do not acknowledge or change data", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 38, + "commits": 11, + "gets": 13, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115411, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39112 + } + ], + "sqlite_runtime": "3.46.1", + "binary_sha256": "2e3bb0802082637989041063c2bc34ad6c9a072abc257d7adefdc65cba315298", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b0b50b0d85900b26257aac68177951e8319fd5482a1fcff4c91722f7ba30f74c", + "prototypes/connections/store.h": "07d347ae872a3d618dda1697b01bb32abee44b3451472a1b2225963085c11cf2", + "prototypes/connections/server.c": "3ff9d9497ad2a8ece657b50af9f8e6e564a974e6bbe3542f0ebe32279883d219", + "prototypes/connections/check.py": "be75db13ab2f11d3dd0e064aaa271bf28145aef11423e8ed536c755dcf96eaac", + "prototypes/connections/test-flow.c": "17472b90ebd3067b43a0cd06fb571111206b2025a2bd552b1602c3189f17ce0b", + "prototypes/connections/Makefile": "6c05917782adf733d31d9bfd42ee8f41b05fe94d965c362577189d6849b61e3e", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..c520aa5 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile @@ -0,0 +1,22 @@ +CC ?= cc +CPPFLAGS += $(shell pkg-config --cflags sqlite3) +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +LDLIBS += $(shell pkg-config --libs sqlite3) +BUILD ?= build + +.PHONY: all check +all: $(BUILD)/pure-connections + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/test-flow $(BUILD)/pure-connections + $(BUILD)/test-flow + python3 check.py --server $(BUILD)/pure-connections $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py new file mode 100644 index 0000000..c19fcaa --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Exercise the real local TCP/SQLite path and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import sqlite3 +import struct +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, database, budget=4 * 1024 * 1024, timeout=5000): + self.process = subprocess.Popen([str(binary), '--db', str(database), '--port', '0', + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory): + database = directory / 'objects.db' + checks, records = [], [] + with serving(binary, database) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + lock = sqlite3.connect(database, timeout=0) + lock.execute('BEGIN IMMEDIATE') + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + lock.rollback() + lock.execute("CREATE TRIGGER reject_write BEFORE UPDATE ON pure_objects BEGIN SELECT RAISE(ABORT,'test failure'); END") + lock.commit() + assert rpc(server, 'PUT', 'binary', b'failure', 2)[0] == 500 + assert rpc(server, 'GET', 'binary')[2] == b'new' + lock.execute('DROP TRIGGER reject_write') + lock.commit() + lock.close() + checks.append('busy and injected DB failure do not acknowledge or change data') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the SQLite row whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock database. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + with sqlite3.connect(database) as database_connection: + database_connection.execute('UPDATE pure_objects SET revision=? WHERE key=?', (9007199254740993, 'wide_revision')) + lines, _ = cli(server, 'connect $URL wide_revision\npull\npush\nquit\n') + revisions = [line['revision'] for line in lines if 'remote' in line and 'revision' in line] + assert revisions == ['9007199254740993', '9007199254740994'] + checks.append('revision above JavaScript safe integer round trips without numeric coercion') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'unread_ack', 4, b'If-None-Match: *\r\n') + b'done') + try: + deadline = time.monotonic() + 3 + while True: + with sqlite3.connect(database) as database_connection: + committed = database_connection.execute('SELECT body FROM pure_objects WHERE key=?', ('unread_ack',)).fetchone() + if committed: + assert committed[0] == b'done' + break + assert time.monotonic() < deadline + time.sleep(0.01) + finally: + sock.close() # Client never consumes the successful response. + assert rpc(server, 'PUT', 'unread_ack', b'again', 0)[0] == 412 + assert rpc(server, 'GET', 'unread_ack')[2] == b'done' + checks.append('unread acknowledgment does not undo commit or permit a stale retry') + + # A disconnect after commit can hide the ACK; retries remain conditional. + assert rpc(server, 'PUT', 'empty', b'', 0)[0] == 200 + assert rpc(server, 'GET', 'empty')[2] == b'' + checks.append('empty body has a valid lifetime and stored revision') + records.append(server.stats) + + with serving(binary, database) as restarted: + assert rpc(restarted, 'GET', 'binary')[2] == b'new' + assert rpc(restarted, 'GET', 'authored')[2] == rpc_bytes(saved) + checks.append('committed authoring state survives process restart') + records.append(restarted.stats) + + # Fixed small payload budget is an admission test, not a throughput workload. + with serving(binary, database, budget=1024 * 1024, timeout=1200) as limited: + sock = socket.create_connection(('127.0.0.1', limited.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 + assert response.read(1) == large[:1] + status, headers, _ = rpc(limited, 'GET', 'large') + assert status == 503 and headers['X-Pure-Error'] == 'payload_budget' + # Leave the first receiver stalled until the absolute connection deadline. + time.sleep(1.4) + assert rpc(limited, 'GET', 'binary')[2] == b'new' + response.close() + sock.close() + checks.append('slow consumer retains its lease; budget rejects another; deadline releases it') + + idle = [socket.create_connection(('127.0.0.1', limited.port), timeout=8) for _ in range(8)] + try: + for s in idle: + s.sendall(b'G') + for s in idle: + assert s.recv(1) == b'' + finally: + for s in idle: + s.close() + assert rpc(limited, 'GET', 'binary')[0] == 200 + checks.append('connection slots stay bounded and recover after header deadlines') + assert limited.stats['budget_rejections'] >= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + return {'kind': 'native-path-correctness-not-performance', 'checks': checks, + 'server_runs': records, 'sqlite_runtime': server.ready['sqlite']} + + +def rpc_bytes(path): + # CLI export is pretty JSON; wire export is compact JSON with the same model. + return json.dumps(json.loads(path.read_text()), ensure_ascii=False, separators=(',', ':')).encode() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--server', type=Path, default=ROOT / 'build/pure-connections') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + binary = args.server.resolve() + with tempfile.TemporaryDirectory(prefix='pure-connections-check-') as temporary: + result = check(binary, Path(temporary)) + result['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + result['environment'] = {'platform': platform.platform(), 'machine': platform.machine(), + 'python': platform.python_version(), 'node': subprocess.check_output(['node', '--version'], text=True).strip()} + result['source_sha256'] = {str(path.relative_to(ROOT.parent.parent)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [*(ROOT / name for name in ('flow.c', 'flow.h', 'store.c', 'store.h', 'server.c', 'check.py', 'test-flow.c', 'Makefile')), + *(WORKBENCH / name for name in ('remote.mjs', 'cli.mjs', 'web/core.wasm', 'web/runtime.mjs', + 'web/compiler.mjs', 'web/project.mjs', 'examples/direct.pure'))]} + output = json.dumps(result, ensure_ascii=False, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c new file mode 100644 index 0000000..8f7b1ed --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c @@ -0,0 +1,346 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received, charged; + unsigned char *body; + bool put; + int64_t expected; + PureStoredView stored; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + size_t budget, retained, peak_retained, active, peak_active; + uint64_t accepted, commits, gets, conflicts, budget_rejections, expired; + uint64_t releases, prefix_copy_bytes, borrowed_output_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + bool owned = c->body || c->stored.db || c->charged; + pure_store_release(&c->stored); + free(c->body); + c->body = NULL; + assert(c->charged <= c->server->retained); + c->server->retained -= c->charged; + c->charged = 0; + if (owned) ++c->server->releases; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else if (c->body || c->stored.db || c->charged) release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static bool charge(Connection *c, size_t size) { + Server *s = c->server; + if (size > s->budget - s->retained) { ++s->budget_rejections; return false; } + s->retained += size; + c->charged += size; + if (s->retained > s->peak_retained) s->peak_retained = s->retained; + return true; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + if (c->body || c->stored.db || c->charged) release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void run_request(Connection *c) { + PureResult result; + int64_t revision = 0; + if (c->put) { + result = pure_store_put(c->server->path, c->key, c->expected, + (PureSpan){c->body, c->body_size}, &revision); + /* Binding has been finalized, including the failure path. */ + release_payload(c); + if (result == PURE_OK) ++c->server->commits; + } else { + result = pure_store_get(c->server->path, c->key, + c->server->budget - c->server->retained, &c->stored); + if (result == PURE_OK) { + if (!charge(c, c->stored.body.size)) abort(); /* Same owner thread. */ + revision = c->stored.revision; + ++c->server->gets; + c->server->borrowed_output_bytes += c->stored.body.size; + } else if (result == PURE_BUDGET) ++c->server->budget_rejections; + } + switch (result) { + case PURE_OK: respond(c, 200, "OK", "none", revision, c->stored.body); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + ++c->server->conflicts; + respond(c, 412, "Precondition Failed", "conflict", revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "db_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + if (!c->put) { run_request(c); return; } + if (!charge(c, c->body_size)) { fail(c, 503, "Service Unavailable", "payload_budget"); return; } + c->body = malloc(c->body_size ? c->body_size : 1); + if (!c->body) { fail(c, 503, "Service Unavailable", "allocation"); return; } + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + for (int i = 1; i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--db")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!server.path) goto usage; + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize SQLite store\n", stderr); return 1; } + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"sqlite\":\"%s\"}\n", ntohs(address.sin_port), sqlite3_libversion()); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.retained, server.peak_retained, server.budget, server.peak_active, + server.accepted, server.commits, server.gets, server.conflicts, server.budget_rejections, + server.expired, server.releases, server.prefix_copy_bytes, server.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --db PATH [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c new file mode 100644 index 0000000..a805f72 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c @@ -0,0 +1,132 @@ +#include "store.h" +#include +#include + +static PureResult error_result(int rc) { + int code = rc & 255; + return code == SQLITE_BUSY || code == SQLITE_LOCKED ? PURE_BUSY : PURE_STORE_ERROR; +} + +static int execute(sqlite3 *db, const char *sql) { + return sqlite3_exec(db, sql, NULL, NULL, NULL); +} + +static int open_db(const char *path, sqlite3 **db, int flags) { + int rc = sqlite3_open_v2(path, db, flags | SQLITE_OPEN_NOMUTEX, NULL); + if (rc != SQLITE_OK) return rc; + sqlite3_busy_timeout(*db, 0); /* No hidden wait/retry on this owner thread. */ + sqlite3_limit(*db, SQLITE_LIMIT_LENGTH, (int)PURE_OBJECT_LIMIT + 4096); + return execute(*db, "PRAGMA cache_size=-64; PRAGMA synchronous=FULL;"); +} + +PureResult pure_store_init(const char *path) { + sqlite3 *db = NULL; + int rc = open_db(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE); + sqlite3_stmt *mode = NULL; + if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL", -1, &mode, NULL); + if (rc == SQLITE_OK) { + rc = sqlite3_step(mode); + if (rc == SQLITE_ROW) { + const unsigned char *name = sqlite3_column_text(mode, 0); + rc = name && strcmp((const char *)name, "wal") == 0 ? SQLITE_OK : SQLITE_ERROR; + } + } + sqlite3_finalize(mode); + if (rc == SQLITE_OK) rc = execute(db, + "CREATE TABLE IF NOT EXISTS pure_objects(" + "key TEXT PRIMARY KEY, revision INTEGER NOT NULL CHECK(revision>0)," + "body BLOB NOT NULL CHECK(typeof(body)='blob'));"); + if (db) sqlite3_close(db); + return rc == SQLITE_OK ? PURE_OK : error_result(rc); +} + +void pure_store_release(PureStoredView *view) { + sqlite3_finalize(view->statement); + if (view->db) { + execute(view->db, "ROLLBACK"); + sqlite3_close(view->db); + } + *view = (PureStoredView){0}; +} + +PureResult pure_store_get(const char *path, const char *key, size_t available, + PureStoredView *out) { + *out = (PureStoredView){0}; + int rc = open_db(path, &out->db, SQLITE_OPEN_READONLY); + if (rc == SQLITE_OK) rc = execute(out->db, "BEGIN"); + if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(out->db, + "SELECT revision,length(body) FROM pure_objects WHERE key=?", -1, &out->statement, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(out->statement, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(out->statement); + PureResult result = PURE_STORE_ERROR; + if (rc == SQLITE_DONE) { result = PURE_NOT_FOUND; goto fail; } + if (rc != SQLITE_ROW) { result = error_result(rc); goto fail; } + out->revision = sqlite3_column_int64(out->statement, 0); + sqlite3_int64 size = sqlite3_column_int64(out->statement, 1); + if (out->revision <= 0 || size < 0) goto fail; + if (size > PURE_OBJECT_LIMIT) { result = PURE_LIMIT; goto fail; } + if ((uint64_t)size > available) { result = PURE_BUDGET; goto fail; } + sqlite3_finalize(out->statement); + out->statement = NULL; + /* Keep the read transaction so admission and body observe the same version. + * The result remains borrowed until socket completion/abort finalizes it. */ + rc = sqlite3_prepare_v2(out->db, "SELECT body FROM pure_objects WHERE key=?", -1, &out->statement, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(out->statement, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(out->statement); + if (rc != SQLITE_ROW) { result = error_result(rc); goto fail; } + if (sqlite3_column_type(out->statement, 0) != SQLITE_BLOB) goto fail; + const unsigned char *data = sqlite3_column_blob(out->statement, 0); + int bytes = sqlite3_column_bytes(out->statement, 0); + if (bytes != size || (bytes && !data)) goto fail; + out->body = (PureSpan){data, (size_t)bytes}; + return PURE_OK; +fail: + pure_store_release(out); + return result; +} + +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, int64_t *revision) { + *revision = 0; + if (body.size > PURE_OBJECT_LIMIT || expected < 0) return PURE_LIMIT; + sqlite3 *db = NULL; + sqlite3_stmt *statement = NULL; + PureResult result = PURE_STORE_ERROR; + int rc = open_db(path, &db, SQLITE_OPEN_READWRITE); + if (rc == SQLITE_OK) rc = execute(db, "BEGIN IMMEDIATE"); + if (rc != SQLITE_OK) { result = error_result(rc); goto finish; } + rc = sqlite3_prepare_v2(db, "SELECT revision FROM pure_objects WHERE key=?", -1, &statement, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(statement, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(statement); + if (rc == SQLITE_ROW) *revision = sqlite3_column_int64(statement, 0); + else if (rc != SQLITE_DONE) { result = error_result(rc); goto finish; } + sqlite3_finalize(statement); + statement = NULL; + if (*revision != expected) { result = PURE_CONFLICT; goto finish; } + if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + const char *sql = *revision ? + "UPDATE pure_objects SET body=?1,revision=?2 WHERE key=?3" : + "INSERT INTO pure_objects(body,revision,key) VALUES(?1,?2,?3)"; + rc = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + static const unsigned char empty = 0; + /* Do not copy at the binding boundary. Finalize before returning the input. */ + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(statement, 1, body.size ? body.data : &empty, + (int)body.size, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(statement, 2, *revision + 1); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(statement, 3, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(statement); + if (rc != SQLITE_DONE) { result = error_result(rc); goto finish; } + rc = sqlite3_finalize(statement); + statement = NULL; + if (rc == SQLITE_OK) rc = execute(db, "COMMIT"); + if (rc != SQLITE_OK) { result = error_result(rc); goto finish; } + ++*revision; + result = PURE_OK; /* Acknowledgment is constructed only after COMMIT. */ +finish: + sqlite3_finalize(statement); + if (db) { + if (!sqlite3_get_autocommit(db)) execute(db, "ROLLBACK"); + sqlite3_close(db); + } + return result; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h new file mode 100644 index 0000000..c0e9f96 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h @@ -0,0 +1,25 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include +#include + +#define PURE_OBJECT_LIMIT ((2u * 6u + 32u) * 32768u + 8192u) +typedef enum { + PURE_OK, PURE_NOT_FOUND, PURE_CONFLICT, PURE_BUSY, PURE_BUDGET, + PURE_LIMIT, PURE_STORE_ERROR +} PureResult; +typedef struct { + sqlite3 *db; + sqlite3_stmt *statement; + 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); +void pure_store_release(PureStoredView *); +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, int64_t *revision); +#endif diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..997e7df --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c @@ -0,0 +1,110 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.db && !first.statement); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.db && !first.statement); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + sqlite3 *other = NULL; + CHECK(sqlite3_open(path, &other) == SQLITE_OK); + CHECK(sqlite3_exec(other, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK); + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_BUSY); + CHECK(sqlite3_exec(other, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(other, "CREATE TRIGGER deny BEFORE UPDATE ON pure_objects " + "BEGIN SELECT RAISE(ABORT,'injected failure'); END", NULL, NULL, NULL) == SQLITE_OK); + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + CHECK(sqlite3_exec(other, "DROP TRIGGER deny; UPDATE pure_objects SET revision=9223372036854775807", NULL, NULL, NULL) == SQLITE_OK); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(sqlite3_close(other) == SQLITE_OK); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256], sidecar[280]; + snprintf(path, sizeof(path), "%s/store.db", directory); + storage(path); + CHECK(unlink(path) == 0); + snprintf(sidecar, sizeof(sidecar), "%s-wal", path); unlink(sidecar); + snprintf(sidecar, sizeof(sidecar), "%s-shm", path); unlink(sidecar); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"snapshot-budget-conflict-busy-rollback-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.test.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.test.mjs new file mode 100644 index 0000000..f355d2d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm new file mode 100644 index 0000000..79c50a8 Binary files /dev/null and b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/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/connections/results/2026-09-23-native-connections/verification.json b/prototypes/connections/results/2026-09-23-native-connections/verification.json new file mode 100644 index 0000000..3a8b2cc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/verification.json @@ -0,0 +1,29 @@ +{ + "scope": "Correctness, lifetimes, admission bounds; no throughput, latency, or RSS comparison", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "native_command": "make check EVIDENCE=results/2026-09-23-native-connections/native.json", + "sanitized_command": "make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-native-connections/sanitized.json", + "core_checks": { + "native": "passed", + "asan_ubsan": "passed", + "leak_detection_disabled": false + }, + "integration_scenarios_each": 15, + "cli_regression": { + "command": "node --test cli.test.mjs", + "cwd": "prototypes/workbench", + "passed": 8, + "failed": 0, + "test_sha256": "7e4688824e26dc7e56396f9d4d62726de0ccb1490e1a388bb64690ae2aae52f4" + }, + "source_hashes_equal": true, + "source_snapshots": "sources/", + "final_binaries_match_records": true, + "limitations": [ + "SQLite shared library was not rebuilt with sanitizers", + "Linux AArch64 only; no macOS execution", + "Bounded payload retention excludes SQLite internals, OS socket buffers and client allocations", + "SQLite operations remain synchronous; absolute deadlines are enforced between those operations", + "Native connection path is fixed in C; Pure-authored external effects are not implemented" + ] +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/README.md b/prototypes/connections/results/2026-09-23-prepared-connections/README.md new file mode 100644 index 0000000..c79e954 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/README.md @@ -0,0 +1,31 @@ +# 020 검증 자료 + +> 필드와 데이터 의존 범위 도입 후 기록은 [021](../2026-09-23-field-connections/README.md)에 있다. +> 이 디렉터리의 원자료·소스 사본은 상수 구간 프로파일 당시의 결과로 보존한다. + +작성한 구간·저장·응답 관계를 준비하고 실제 네이티브 TCP 경로에서 실행한 기록이다. +속도·처리량·RSS 비교는 하지 않았다. + +- [native.json](native.json): 일반 빌드의 22개 통합 시나리오, 준비 결과, 종료 자원, 실행 파일·소스 해시. +- [sanitized.json](sanitized.json): ASan·UBSan 빌드의 같은 검사. 누수 검사를 끄지 않았다. +- [verification.json](verification.json): 명령, 저장/전송 및 준비 단위 검사 결과, 소스·바이너리 일치 확인. +- `sources/`: 기록된 파일의 사본. 현재 작업 트리가 바뀐 뒤에는 별도 작업 트리에 배치해 재현한다. + +`prepared_connections`는 검사 시 작성한 정의에서 나온 구간·길이 조건이다. +`runtime_view_steps`는 요청별 뷰 노드 실행기가 없다는 구조 설명값이고 시간이나 CPU 명령 +계측값은 아니다. `prepared_program_bytes`는 준비 구조체 하나의 `sizeof`다. 전체 메모리와 +혼동하지 않는다. 부모 구간이나 쓰지 않는 구간도 유효해야 하므로 최소 원본 길이가 최종 +응답 범위보다 클 수 있다. + +논리적 본문 예산 16 B로 1 MiB 파일에서 작성한 16 B를 응답한 검사는 요구 범위 전달을 +확인한다. mmap의 페이지·정렬 비용, 파일 보유 공간, OS 버퍼, 전체 RSS는 이 예산 밖이다. +입력의 일부를 응답하는 경우에는 원본 수신 할당 전체를 유지·계상한다. + +기존 저장 단위 검사 외에 `test-program`은 준비 결과를 순차 범위 검사와 대조하며, +잘못된 이름·타입·효과 위치·상수 범위·소스 한도 등을 거절하는지 확인한다. 기본 작성 파일로 +기존 Workbench 조작·저장·새 실행기 복원이 유지되는 것도 통합 검사에 포함했다. + +재현은 [구현 README](../../README.md)의 `make check`를 따른다. +`EVIDENCE=/새/경로.json`으로 원자료를 기록하며 기존 파일을 덮어쓰지 않는다. 이 환경은 +소켓·자식 프로세스·LeakSanitizer 제한 때문에 승인된 환경에서 로컬 검사를 실행했다. +외부 서비스는 사용하지 않았으며 macOS와 실제 전원 장애는 확인하지 않았다. diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/native.json b/prototypes/connections/results/2026-09-23-prepared-connections/native.json new file mode 100644 index 0000000..4e04783 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/native.json @@ -0,0 +1,269 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests" + ], + "prepared_connections": { + "excerpt": { + "read": { + "whole": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "whole": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "prepared_program_bytes": 112 + }, + "write": { + "read": { + "whole": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "whole": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "prepared_program_bytes": 112 + } + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 11, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115417, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 39288 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "ce76b1ff164623c8575c8c313a97ec4083c8a500eef88ab49072bc2e04d7520d", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "7a506fb79a074f0d90eaa6f047a364e4fee28eea9ebea1f869dbd3770edc6a2e", + "prototypes/connections/store.h": "05c5a4e35e27a4d309c3209c84112fd38d65093ece79ddc4eb1b68adb8e5741f", + "prototypes/connections/program.c": "b6b848b261aa9da455126ec25fff0494b4eefb649ce0fd036f075c1efb41f9c8", + "prototypes/connections/program.h": "700880cba88b18b08c1b87aa4da46fedc81f3d665d74e40fc659095fe4c3f014", + "prototypes/connections/server.c": "d2cb3cc8d47cb5e6bf258cf62c70c039b5c5e945fa5c4d5edb7917d50395fe73", + "prototypes/connections/check.py": "7321ba3a48a10a7d1344bbf61df3fb693e9ac95e00d26d3d247bd6a39997505f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "d58248c65d59fa31f37ba50aa9ad761d3e9698d1e17d61307b7e88089c70d385", + "prototypes/connections/Makefile": "85bc73e364fb5a273efd7bfbd2e8118091f634d0e263d0729bea445252dcd7f0", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sanitized.json b/prototypes/connections/results/2026-09-23-prepared-connections/sanitized.json new file mode 100644 index 0000000..d5cc604 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sanitized.json @@ -0,0 +1,269 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests" + ], + "prepared_connections": { + "excerpt": { + "read": { + "whole": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "whole": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "prepared_program_bytes": 112 + }, + "write": { + "read": { + "whole": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "whole": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "prepared_program_bytes": 112 + } + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 39288 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 39288 + } + ], + "storage": "immutable-byte-extents-v1", + "binary_sha256": "3cb873b98d64992cc89f11186de1e97baf19fd7aa3fcb5784b061bea7828b206", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "7a506fb79a074f0d90eaa6f047a364e4fee28eea9ebea1f869dbd3770edc6a2e", + "prototypes/connections/store.h": "05c5a4e35e27a4d309c3209c84112fd38d65093ece79ddc4eb1b68adb8e5741f", + "prototypes/connections/program.c": "b6b848b261aa9da455126ec25fff0494b4eefb649ce0fd036f075c1efb41f9c8", + "prototypes/connections/program.h": "700880cba88b18b08c1b87aa4da46fedc81f3d665d74e40fc659095fe4c3f014", + "prototypes/connections/server.c": "d2cb3cc8d47cb5e6bf258cf62c70c039b5c5e945fa5c4d5edb7917d50395fe73", + "prototypes/connections/check.py": "7321ba3a48a10a7d1344bbf61df3fb693e9ac95e00d26d3d247bd6a39997505f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "d58248c65d59fa31f37ba50aa9ad761d3e9698d1e17d61307b7e88089c70d385", + "prototypes/connections/Makefile": "85bc73e364fb5a273efd7bfbd2e8118091f634d0e263d0729bea445252dcd7f0", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..50ad1cb --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/Makefile @@ -0,0 +1,24 @@ +CC ?= cc +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +BUILD ?= build + +.PHONY: all check +all: $(BUILD)/pure-connections + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c store.c flow.c program.c store.h flow.h program.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c store.c flow.c program.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) -DPURE_STORE_TEST test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-program: test-program.c program.c program.h store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-program.c program.c $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/test-flow $(BUILD)/test-program $(BUILD)/pure-connections + $(BUILD)/test-flow + $(BUILD)/test-program + python3 check.py --server $(BUILD)/pure-connections $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/check.py new file mode 100644 index 0000000..c547ebf --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/check.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python3 +"""Exercise local TCP, immutable byte extents, and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import fcntl +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import struct +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, storage, budget=4 * 1024 * 1024, timeout=5000, program=None): + self.process = subprocess.Popen([str(binary), '--store', str(storage), '--port', '0', + '--program', str(program or ROOT / 'examples/storage.pure'), + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory): + storage = directory / 'objects' + checks, records = [], [] + with serving(binary, storage) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + with (storage / '.writer.lock').open('r+b') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + fcntl.lockf(lock, fcntl.LOCK_UN) + broken = storage / 'obj-broken' + broken.write_bytes(b'incomplete') + assert rpc(server, 'GET', 'broken')[0] == 500 + assert rpc(server, 'PUT', 'broken', b'failure', 0)[0] == 500 + assert broken.read_bytes() == b'incomplete' + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('writer contention and invalid extent fail without overwriting data') + + with serving(binary, storage) as second_writer: + with ThreadPoolExecutor(max_workers=2) as pool: + tasks = [pool.submit(rpc, target, 'PUT', 'race', value, 0) + for target, value in [(server, b'first'), (second_writer, b'second')]] + statuses = [task.result()[0] for task in tasks] + assert statuses.count(200) == 1 and all(status in [200, 412, 503] for status in statuses) + status, headers, body = rpc(server, 'GET', 'race') + assert status == 200 and headers['ETag'] == '"1"' and body in [b'first', b'second'] + records.append(second_writer.stats) + checks.append('two native writers cannot both create the same expected revision') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the immutable file whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock storage. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + wide = storage / 'obj-wide_revision' + raw = wide.read_bytes() + wide.write_bytes(raw[:8] + struct.pack('= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + prepared = authored_connections(binary, directory, storage, records, checks) + return {'kind': 'prepared-connection-correctness-not-performance', 'checks': checks, + 'prepared_connections': prepared, + 'server_runs': records, 'storage': server.ready['storage']} + + +def authored_connections(binary, directory, storage, records, checks): + get = 'on get { let o = store.read(request.key); reply(o, o.body); }' + put = 'on put { let s = store.write(request.key, request.revision, request.body); reply(s); }' + + def author(name, source): + path = directory / name + path.write_text(source) + return path + + def explain(path): + return json.loads(subprocess.check_output([str(binary), '--check-program', str(path)], text=True)) + + with serving(binary, storage) as writer: + assert rpc(writer, 'PUT', 'short_parent', bytes(range(48)), 0)[0] == 200 + assert rpc(writer, 'PUT', 'short_unused', bytes(range(80)), 0)[0] == 200 + records.append(writer.stats) + excerpt = ROOT / 'examples/excerpt.pure' + prepared = {'excerpt': explain(excerpt)} + assert prepared['excerpt']['read'] == { + 'whole': False, 'offset': 32, 'length': 16, 'minimum_source_bytes': 80} + with serving(binary, storage, budget=16, program=excerpt) as selected: + status, headers, body = rpc(selected, 'GET', 'large') + assert status == 200 and body == bytes(range(32, 48)) and headers['ETag'] == '"3"' + # Final [32,48) fits, but the authored parent [16,80) does not. + assert rpc(selected, 'GET', 'short_parent')[0] == 413 + assert rpc(selected, 'GET', 'missing')[0] == 404 + assert selected.stats['peak_retained_payload_bytes'] == 16 + records.append(selected.stats) + checks.append('authored nested read maps only final demand while preserving parent bounds and revision') + + unused = author('unused.pure', 'on get { let o = store.read(request.key); ' + 'let unused = range(o.body, 0, 96); let body = range(o.body, 32, 16); reply(o, body); } ' + put) + with serving(binary, storage, budget=16, program=unused) as guarded: + assert rpc(guarded, 'GET', 'short_unused')[0] == 413 + assert rpc(guarded, 'GET', 'large')[2] == bytes(range(32, 48)) + records.append(guarded.stats) + checks.append('unused checked view still fails even when response range would be valid') + + select_write = author('write.pure', get + ' on put { let a = range(request.body, 2, 10); ' + 'let b = range(a, 3, 4); let alias = b; ' + 'let saved = store.write(request.key, request.revision, alias); reply(saved, b); }') + prepared['write'] = explain(select_write) + with serving(binary, storage, program=select_write) as writer: + status, headers, body = rpc(writer, 'PUT', 'selected', b'0123456789AB', 0) + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'GET', 'selected')[2] == b'5678' + assert rpc(writer, 'PUT', 'selected', b'012345678', 1)[0] == 413 + status, headers, body = rpc(writer, 'GET', 'selected') + assert status == 200 and body == b'5678' and headers['ETag'] == '"1"' + assert rpc(writer, 'PUT', 'selected', b'0123456789AB', 0)[0] == 412 + records.append(writer.stats) + checks.append('authored write and reply share selected input; invalid parent prevents the storage effect') + + metadata = author('metadata.pure', 'on get { let o = store.read(request.key); reply(o); } ' + put) + with serving(binary, storage, budget=1, program=metadata) as meta: + status, headers, body = rpc(meta, 'GET', 'large') + assert status == 200 and headers['ETag'] == '"3"' and body == b'' + assert rpc(meta, 'GET', 'absent')[0] == 404 + assert rpc(meta, 'GET', 'broken')[0] == 500 + assert meta.stats['peak_retained_payload_bytes'] == 0 + records.append(meta.stats) + checks.append('body-free response preserves read existence and malformed-object errors') + + echo = author('echo.pure', get + ' on put { let body = request.body; ' + 'let saved = store.write(request.key, request.revision, body); reply(saved, body); }') + payload = bytes(range(256)) * 4096 + with serving(binary, storage, budget=len(payload), program=echo) as echoed: + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo', len(payload), b'If-None-Match: *\r\n') + payload) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.read(1) == payload[:1] + assert rpc(echoed, 'GET', 'binary')[0] == 503 + assert response.read() == payload[1:] + response.close() + assert rpc(echoed, 'GET', 'binary')[2] == b'new' + with socket.create_connection(('127.0.0.1', echoed.port), timeout=8) as sock: + sock.sendall(header('PUT', 'echo_abort', len(payload), b'If-None-Match: *\r\n') + payload) + assert sock.recv(1) + assert rpc(echoed, 'GET', 'binary')[0] == 503 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + deadline = time.monotonic() + 3 + while rpc(echoed, 'GET', 'binary')[0] == 503: + assert time.monotonic() < deadline + time.sleep(0.01) + assert rpc(echoed, 'GET', 'echo_abort')[2] == payload + records.append(echoed.stats) + checks.append('input-backed response survives partial send and releases on finish or abort without undoing commit') + + bad = author('invalid.pure', get + get + put) + untouched = directory / 'must_not_exist' + rejected = subprocess.run([str(binary), '--program', str(bad), '--store', str(untouched)], + capture_output=True, text=True, timeout=10) + assert rejected.returncode == 2 and 'duplicate handler' in rejected.stderr and not untouched.exists() + checks.append('invalid authored program fails preparation before creating storage or accepting requests') + return prepared + + +def rpc_bytes(path): + # CLI export is pretty JSON; wire export is compact JSON with the same model. + return json.dumps(json.loads(path.read_text()), ensure_ascii=False, separators=(',', ':')).encode() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--server', type=Path, default=ROOT / 'build/pure-connections') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + binary = args.server.resolve() + with tempfile.TemporaryDirectory(prefix='pure-connections-check-') as temporary: + result = check(binary, Path(temporary)) + result['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + result['environment'] = {'platform': platform.platform(), 'machine': platform.machine(), + 'python': platform.python_version(), 'node': subprocess.check_output(['node', '--version'], text=True).strip()} + result['source_sha256'] = {str(path.relative_to(ROOT.parent.parent)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [*(ROOT / name for name in ('flow.c', 'flow.h', 'store.c', 'store.h', 'program.c', 'program.h', + 'server.c', 'check.py', 'test-flow.c', 'test-program.c', 'Makefile', 'examples/storage.pure', 'examples/excerpt.pure')), + *(WORKBENCH / name for name in ('remote.mjs', 'cli.mjs', 'web/core.wasm', 'web/runtime.mjs', + 'web/compiler.mjs', 'web/project.mjs', 'examples/direct.pure'))]} + output = json.dumps(result, ensure_ascii=False, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/excerpt.pure b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/excerpt.pure new file mode 100644 index 0000000..622f9bb --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/excerpt.pure @@ -0,0 +1,13 @@ +// Read bytes 32..47 of an object that must contain the complete 64-byte window. +// Preparation keeps the size check for 16..79, and maps only 32..47 for reply. +on get { + let object = store.read(request.key); + let window = range(object.body, 16, 64); + let excerpt = range(window, 16, 16); + reply(object, excerpt); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/storage.pure b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/storage.pure new file mode 100644 index 0000000..ff54ea3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/examples/storage.pure @@ -0,0 +1,11 @@ +// The host supplies request bytes, an object name, and the expected revision. +// Failed storage results propagate to the host's response without executing reply. +on get { + let object = store.read(request.key); + reply(object, object.body); +} + +on put { + let saved = store.write(request.key, request.revision, request.body); + reply(saved); +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include +#include + +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/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.c b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.c new file mode 100644 index 0000000..79101db --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.c @@ -0,0 +1,233 @@ +#include "program.h" +#include "store.h" +#include +#include +#include + +enum { NAME = 256, NUMBER, SYMBOLS = 32 }; +typedef struct { + char name[32]; + bool result; + PureRegion region; +} Symbol; +typedef struct { + const char *source; + size_t length, at, line, token_line; + int token; + char name[32]; + size_t number; + char *error; + size_t error_size; + bool failed, put, effect; + Symbol symbols[SYMBOLS]; + size_t count; + PureProgram program; +} Parser; + +static bool reject(Parser *p, const char *message) { + if (!p->failed && p->error_size) snprintf(p->error, p->error_size, "line %zu: %s", p->token_line, message); + p->failed = true; + return false; +} + +static bool letter(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; +} + +static bool next(Parser *p) { + while (p->at < p->length) { + char c = p->source[p->at]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (c == '\n') ++p->line; + ++p->at; + } else if (c == '/' && p->at + 1 < p->length && p->source[p->at + 1] == '/') { + while (p->at < p->length && p->source[p->at] != '\n') ++p->at; + } else break; + } + p->token_line = p->line; + p->name[0] = 0; + p->token = 0; + if (p->at == p->length) return true; + char c = p->source[p->at++]; + if (letter(c)) { + size_t start = p->at - 1; + while (p->at < p->length && (letter(p->source[p->at]) || + (p->source[p->at] >= '0' && p->source[p->at] <= '9'))) ++p->at; + size_t size = p->at - start; + if (size >= sizeof(p->name)) return reject(p, "name exceeds 31 bytes"); + memcpy(p->name, p->source + start, size); + p->name[size] = 0; + p->token = NAME; + } else if (c >= '0' && c <= '9') { + size_t value = (size_t)(c - '0'); + while (p->at < p->length && p->source[p->at] >= '0' && p->source[p->at] <= '9') { + unsigned digit = (unsigned)(p->source[p->at++] - '0'); + if (value > (SIZE_MAX - digit) / 10) return reject(p, "integer overflow"); + value = value * 10 + digit; + } + p->number = value; + p->token = NUMBER; + } else if (c && strchr("{}().,=;", c)) p->token = (unsigned char)c; + else return reject(p, "unexpected character"); + return true; +} + +static bool is_word(Parser *p, const char *word) { + return p->token == NAME && !strcmp(p->name, word); +} + +static bool word(Parser *p, const char *expected) { + return is_word(p, expected) ? next(p) : reject(p, expected); +} + +static bool token(Parser *p, int expected) { + return p->token == expected ? next(p) : reject(p, "unexpected token"); +} + +static Symbol *find(Parser *p, const char *name) { + for (size_t i = 0; i < p->count; ++i) + if (!strcmp(name, p->symbols[i].name)) return &p->symbols[i]; + return NULL; +} + +static bool field(Parser *p, const char *object, const char *name) { + return word(p, object) && token(p, '.') && word(p, name); +} + +static bool reference(Parser *p, PureRegion *out) { + if (is_word(p, "request")) { + if (!p->put) return reject(p, "request.body is only available in on put"); + *out = (PureRegion){.whole = true}; + return field(p, "request", "body"); + } + Symbol *s = p->token == NAME ? find(p, p->name) : NULL; + if (!s) return reject(p, "unknown byte source"); + bool result = s->result; + *out = s->region; + if (!next(p)) return false; + if (!result) return true; + if (p->put) return reject(p, "write result has no body; use the input value"); + *out = (PureRegion){.whole = true}; + return token(p, '.') && word(p, "body"); +} + +static bool view(Parser *p, PureRegion *out) { + if (!is_word(p, "range")) return reference(p, out); + PureRegion parent; + size_t offset, length; + if (!next(p) || !token(p, '(') || !reference(p, &parent) || !token(p, ',')) return false; + if (p->token != NUMBER) return reject(p, "range offset must be a constant integer"); + offset = p->number; + if (!next(p) || !token(p, ',')) return false; + if (p->token != NUMBER) return reject(p, "range length must be a constant integer"); + length = p->number; + if (!next(p) || !token(p, ')')) return false; + size_t bound = parent.whole ? PURE_OBJECT_LIMIT : parent.length; + if (offset > bound || length > bound - offset) return reject(p, "range exceeds its source bound"); + *out = (PureRegion){.offset = parent.offset + offset, .length = length}; + size_t required = out->offset + out->length; + size_t *minimum = p->put ? &p->program.input_required : &p->program.read_required; + if (required > *minimum) *minimum = required; + ++p->program.range_definitions; + return true; +} + +static bool effect(Parser *p, Symbol *out) { + if (p->effect) return reject(p, "only one storage effect is supported per handler"); + if (!word(p, "store") || !token(p, '.') || !word(p, p->put ? "write" : "read") || + !token(p, '(') || !field(p, "request", "key")) return false; + if (p->put) { + if (!token(p, ',') || !field(p, "request", "revision") || !token(p, ',') || + !reference(p, &p->program.write)) return false; + } + if (!token(p, ')')) return false; + out->result = true; + p->effect = true; + return true; +} + +static bool handler(Parser *p) { + p->count = 0; + p->effect = false; + if (!token(p, '{')) return false; + while (is_word(p, "let")) { + if (p->put && p->effect) return reject(p, "all views must precede store.write; reply must follow it"); + if (p->count == SYMBOLS) return reject(p, "handler exceeds 32 definitions"); + if (!next(p)) return false; + if (p->token != NAME || is_word(p, "request") || is_word(p, "store") || is_word(p, "range")) + return reject(p, "expected a non-reserved definition name"); + if (find(p, p->name)) return reject(p, "duplicate definition"); + Symbol symbol = {0}; + strcpy(symbol.name, p->name); + if (!next(p) || !token(p, '=')) return false; + if (is_word(p, "store")) { + if (!effect(p, &symbol)) return false; + } else { + if (!view(p, &symbol.region)) return false; + ++p->program.view_definitions; + } + if (!token(p, ';')) return false; + p->symbols[p->count++] = symbol; + } + if (!p->effect) return reject(p, "handler requires its storage effect"); + if (!word(p, "reply") || !token(p, '(')) return false; + Symbol *result = p->token == NAME ? find(p, p->name) : NULL; + if (!result || !result->result) return reject(p, "reply requires this handler's storage result"); + if (!next(p)) return false; + PureRegion body = {0}; + bool has_body = p->token == ','; + if (has_body && (!next(p) || !reference(p, &body))) return false; + if (!token(p, ')') || !token(p, ';') || !token(p, '}')) return false; + if (p->put) { + p->program.reply = body; + p->program.reply_input = has_body; + } else p->program.read = body; + return true; +} + +bool pure_program_parse(const char *source, size_t length, PureProgram *out, + char *error, size_t error_size) { + Parser p = {.source = source, .length = length, .line = 1, .token_line = 1, + .error = error, .error_size = error_size}; + if (error_size) error[0] = 0; + if (length > PURE_PROGRAM_LIMIT) return reject(&p, "program exceeds 16 KiB"); + if (!next(&p)) return false; + unsigned handlers = 0; + while (p.token) { + if (!word(&p, "on")) return false; + if (!is_word(&p, "get") && !is_word(&p, "put")) return reject(&p, "expected get or put handler"); + p.put = is_word(&p, "put"); + unsigned bit = p.put ? 2 : 1; + if (handlers & bit) return reject(&p, "duplicate handler"); + handlers |= bit; + if (!next(&p) || !handler(&p)) return false; + } + if (handlers != 3) return reject(&p, "both get and put handlers are required"); + *out = p.program; /* Invalid preparation never changes the installed plan. */ + return true; +} + +bool pure_program_load(const char *path, PureProgram *out, char *error, size_t error_size) { + FILE *file = fopen(path, "rb"); + if (!file) { + if (error_size) snprintf(error, error_size, "cannot open program"); + return false; + } + char source[PURE_PROGRAM_LIMIT + 1]; + size_t length = fread(source, 1, sizeof(source), file); + bool bad = ferror(file) != 0; + if (fclose(file)) bad = true; + if (bad) { + if (error_size) snprintf(error, error_size, "cannot read program"); + return false; + } + return pure_program_parse(source, length, out, error, error_size); +} + +bool pure_region_span(PureRegion region, PureSpan source, PureSpan *out) { + *out = (PureSpan){0}; + if (region.whole) { *out = source; return true; } + if (region.offset > source.size || region.length > source.size - region.offset) return false; + *out = (PureSpan){region.length ? source.data + region.offset : NULL, region.length}; + return true; +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.h b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.h new file mode 100644 index 0000000..29764db --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/program.h @@ -0,0 +1,26 @@ +#ifndef PURE_PROGRAM_H +#define PURE_PROGRAM_H +#include "flow.h" + +#define PURE_PROGRAM_LIMIT 16384u +typedef struct { + size_t offset, length; + bool whole; +} PureRegion; + +/* 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; + bool reply_input; +} 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); +#endif diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/server.c new file mode 100644 index 0000000..3822c66 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/server.c @@ -0,0 +1,385 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include "program.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received, charged; + unsigned char *body; + bool put; + int64_t expected; + PureStoredView stored; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + PureProgram program; + size_t budget, retained, peak_retained, active, peak_active; + uint64_t accepted, commits, gets, conflicts, budget_rejections, expired; + uint64_t releases, prefix_copy_bytes, borrowed_output_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + bool owned = c->body || c->stored.mapped_size || c->charged; + pure_store_release(&c->stored); + free(c->body); + c->body = NULL; + assert(c->charged <= c->server->retained); + c->server->retained -= c->charged; + c->charged = 0; + if (owned) ++c->server->releases; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else if (c->body || c->stored.mapped_size || c->charged) release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static bool charge(Connection *c, size_t size) { + Server *s = c->server; + if (size > s->budget - s->retained) { ++s->budget_rejections; return false; } + s->retained += size; + c->charged += size; + if (s->retained > s->peak_retained) s->peak_retained = s->retained; + return true; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + if (c->body || c->stored.mapped_size || c->charged) release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void run_request(Connection *c) { + PureResult result; + int64_t revision = 0; + PureProgram *program = &c->server->program; + PureSpan output = {0}; + if (c->put) { + PureSpan source = {c->body, c->body_size}, value; + /* Validate every authored view before performing an external effect, + * including unused views. Static preparation preserves these guards. */ + if (source.size < program->input_required || + !pure_region_span(program->write, source, &value) || + (program->reply_input && !pure_region_span(program->reply, source, &output))) { + fail(c, 413, "Content Too Large", "limit"); + return; + } + result = pure_store_put(c->server->path, c->key, c->expected, value, &revision); + /* A reply can consume the same input. Its owner lives until transfer + * completion; otherwise storage completion ends the input lifetime. */ + if (result != PURE_OK || !output.size) { release_payload(c); output = (PureSpan){0}; } + if (result == PURE_OK) { + ++c->server->commits; + c->server->borrowed_output_bytes += output.size; + } + } else { + result = pure_store_select(c->server->path, c->key, program->read.offset, + program->read.length, program->read.whole, program->read_required, + c->server->budget - c->server->retained, &c->stored); + if (result == PURE_OK) { + if (!charge(c, c->stored.body.size)) abort(); /* Same owner thread. */ + revision = c->stored.revision; + output = c->stored.body; + ++c->server->gets; + c->server->borrowed_output_bytes += c->stored.body.size; + } else if (result == PURE_BUDGET) ++c->server->budget_rejections; + } + switch (result) { + case PURE_OK: respond(c, 200, "OK", "none", revision, output); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + ++c->server->conflicts; + respond(c, 412, "Precondition Failed", "conflict", revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "store_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + case PURE_UNCERTAIN: fail(c, 500, "Internal Server Error", "durability_unknown"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + if (!c->put) { run_request(c); return; } + if (!charge(c, c->body_size)) { fail(c, 503, "Service Unavailable", "payload_budget"); return; } + c->body = malloc(c->body_size ? c->body_size : 1); + if (!c->body) { fail(c, 503, "Service Unavailable", "allocation"); return; } + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + const char *program_path = NULL; + bool explain = argc == 3 && !strcmp(argv[1], "--check-program"); + char program_error[160]; + if (explain) program_path = argv[2]; + for (int i = 1; !explain && i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--store")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--program")) program_path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!program_path || (!explain && !server.path)) goto usage; + if (!pure_program_load(program_path, &server.program, program_error, sizeof(program_error))) { + fprintf(stderr, "Program: %s\n", program_error); return 2; + } + if (explain) { + PureProgram *p = &server.program; + printf("{\"read\":{\"whole\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"write\":{\"whole\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"reply_input\":%s,\"view_definitions\":%zu,\"range_definitions\":%zu," + "\"runtime_view_steps\":0,\"prepared_program_bytes\":%zu}\n", + p->read.whole ? "true" : "false", p->read.offset, p->read.length, p->read_required, + p->write.whole ? "true" : "false", p->write.offset, p->write.length, p->input_required, + p->reply_input ? "true" : "false", p->view_definitions, p->range_definitions, sizeof(*p)); + return 0; + } + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize byte store directory\n", stderr); return 1; } + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"storage\":\"immutable-byte-extents-v1\"}\n", ntohs(address.sin_port)); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.retained, server.peak_retained, server.budget, server.peak_active, + server.accepted, server.commits, server.gets, server.conflicts, server.budget_rejections, + server.expired, server.releases, server.prefix_copy_bytes, server.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --program FILE --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n" + "pure-connections --check-program FILE\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.c new file mode 100644 index 0000000..759f210 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.c @@ -0,0 +1,227 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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 (whole) { offset = 0; length = size; } + 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); +} + +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, 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; + 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; + } + fd = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (fd >= 0) { + size_t size; + if (!read_header(fd, revision, &size)) goto finish; + close(fd); + fd = -1; + } else if (errno != ENOENT) goto finish; + if (*revision != expected) { result = PURE_CONFLICT; goto finish; } + if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + 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, body.size); + if (!write_bytes(fd, header, sizeof(header)) || fault(PURE_AFTER_HEADER) || + !write_bytes(fd, body.data, body.size) || 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 (*temporary) unlinkat(directory, temporary, 0); + if (lock >= 0) close(lock); + close(directory); + return result; +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.h new file mode 100644 index 0000000..b1fbea7 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/store.h @@ -0,0 +1,39 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include + +#define PURE_OBJECT_LIMIT ((2u * 6u + 32u) * 32768u + 8192u) +typedef enum { + PURE_OK, PURE_NOT_FOUND, PURE_CONFLICT, PURE_BUSY, PURE_BUDGET, + PURE_LIMIT, PURE_STORE_ERROR, PURE_UNCERTAIN +} PureResult; +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. */ +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); + +/* 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/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..5ff028d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-flow.c @@ -0,0 +1,183 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + long page = sysconf(_SC_PAGESIZE); + CHECK(page >= 128 && (size_t)page <= PURE_OBJECT_LIMIT / 3); + size_t size = (size_t)page * 3, offset = (size_t)page + 13, length = (size_t)page + 105; + unsigned char *bytes = malloc(size); + CHECK(bytes); + for (size_t i = 0; i < size; ++i) bytes[i] = (unsigned char)((i * 17u) ^ (i >> 8) ^ (i >> 16)); + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, size}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", offset, length, length, &first) == PURE_OK); + CHECK(first.body.size == length && !memcmp(first.body.data, bytes + offset, length)); + pure_store_release(&first); + free(bytes); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-program.c b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-program.c new file mode 100644 index 0000000..b8deeba --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/connections/test-program.c @@ -0,0 +1,111 @@ +#include "program.h" +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static PureProgram parse(const char *source) { + PureProgram program; + char error[160]; + if (!pure_program_parse(source, strlen(source), &program, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return program; +} +static void invalid(const char *source) { + PureProgram program; + memset(&program, 0x5a, sizeof(program)); + unsigned char original[sizeof(program)]; + memcpy(original, &program, sizeof(program)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &program, error, sizeof(error))); + CHECK(*error && !memcmp(original, &program, sizeof(program))); +} +int main(void) { + char source[4096]; + snprintf(source, sizeof(source), "%s %s", get, put); + PureProgram plan = parse(source); + CHECK(plan.read.whole && plan.write.whole && !plan.reply_input); + CHECK(!plan.view_definitions && !plan.read_required && !plan.input_required); + snprintf(source, sizeof(source), "%s on put { let a = range(request.body, 2, 10); " + "let b = range(a, 3, 4); let alias = b; " + "let saved = store.write(request.key, request.revision, alias); reply(saved, b); }", get); + plan = parse(source); + CHECK(plan.write.offset == 5 && plan.write.length == 4 && !plan.write.whole); + CHECK(plan.reply.offset == 5 && plan.reply.length == 4 && plan.reply_input); + CHECK(plan.input_required == 12 && plan.range_definitions == 2 && plan.view_definitions == 3); + /* Ordered reference views keep even unused bounds failures observable. */ + unsigned char bytes[160]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 7); + size_t comparisons = 0; + for (size_t a = 0; a < 4; ++a) for (size_t b = 0; b < 6; ++b) + for (size_t c = 0; c <= b; ++c) for (size_t d = 0; d <= b - c; ++d) { + size_t unused_end = (a + b + c) % 12; + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); " + "let unused = range(o.body, %zu, 0); let first = range(o.body, %zu, %zu); " + "let second = range(first, %zu, %zu); reply(o, second); } %s", + unused_end, a, b, c, d, put); + plan = parse(source); + for (size_t n = 0; n < 16; ++n) { + bool reference_ok = unused_end <= n && a <= n && b <= n - a; + PureSpan selected; + bool prepared_ok = n >= plan.read_required && + pure_region_span(plan.read, (PureSpan){bytes, n}, &selected); + CHECK(reference_ok == prepared_ok); + if (reference_ok) { + CHECK(selected.size == d); + CHECK(!d || !memcmp(selected.data, bytes + a + c, d)); + } + ++comparisons; + } + } + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); reply(o); } %s", put); + plan = parse(source); + CHECK(!plan.read.whole && !plan.read.length); + const char *bad_get[] = { + "on get { let o = store.read(request.key); let x = range(o.body, 0, 2); let y = range(x, 2, 1); reply(o, y); }", + "on get { let o = store.read(request.key); let x = range(o.body, 184467440737095516160, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let x = range(o.body, 1449984, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let o = o.body; reply(o); }", + "on get { let o = store.read(request.key); let x = x; reply(o, x); }", + "on get { let o = store.read(request.key); let x = store.read(request.key); reply(x); }", + "on get { let o = store.read(request.key); reply(o, request.body); }", + "on get { let o = store.read(request.key); let x = o.body; reply(x); }", + "on get { let o = store.read(request.key); reply(o.body); }", + "on get { let o = store.read(request.key); reply(o); let x = o.body; }", + "on get { let o = store.read(request.key); reply(o);", "on get {}" + }; + for (size_t i = 0; i < sizeof(bad_get) / sizeof(*bad_get); ++i) { + snprintf(source, sizeof(source), "%s %s", bad_get[i], put); invalid(source); + } + const char *bad_put[] = { + "on put { let s = store.write(request.key, request.revision, request.body); let b = range(request.body, 0, 1); reply(s, b); }", + "on put { let s = store.write(request.key, request.revision, request.body); reply(s, s.body); }", + "on put { let s = store.read(request.key); reply(s); }" + }; + for (size_t i = 0; i < sizeof(bad_put) / sizeof(*bad_put); ++i) { + snprintf(source, sizeof(source), "%s %s", get, bad_put[i]); invalid(source); + } + invalid(get); + snprintf(source, sizeof(source), "%s %s %s", get, get, put); invalid(source); + snprintf(source, sizeof(source), "%s %s garbage", get, put); invalid(source); + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); "); + for (size_t i = 0; i < 32; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let x%zu = o.body; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "reply(o); } %s", put); invalid(source); + char oversized[PURE_PROGRAM_LIMIT + 1]; + memset(oversized, ' ', sizeof(oversized)); + char error[160]; + CHECK(!pure_program_parse(oversized, sizeof(oversized), &plan, error, sizeof(error))); + const char embedded[] = "on get\0{}"; + CHECK(!pure_program_parse(embedded, sizeof(embedded) - 1, &plan, error, sizeof(error))); + PureSpan selected; + CHECK(pure_region_span((PureRegion){0}, (PureSpan){0}, &selected) && !selected.data && !selected.size); + printf("{\"program\":\"prepare-views-preserve-guards-types-effects-and-atomic-install\"," + "\"boundary_semantic_comparisons\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/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/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/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/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/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/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/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/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/core.wasm new file mode 100644 index 0000000..79c50a8 Binary files /dev/null and b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/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/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-prepared-connections/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/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/connections/results/2026-09-23-prepared-connections/verification.json b/prototypes/connections/results/2026-09-23-prepared-connections/verification.json new file mode 100644 index 0000000..2c4036d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-prepared-connections/verification.json @@ -0,0 +1,51 @@ +{ + "date": "2026-09-23", + "scope": "Authored storage/response connections, prepared range semantics, lifetime correctness; no performance comparison", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "os_page_size_bytes": 16384, + "cwd": "prototypes/connections", + "native_command": "make check EVIDENCE=results/2026-09-23-prepared-connections/native.json", + "sanitized_command": "make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-prepared-connections/sanitized.json", + "core_checks": { + "test-flow": { + "native": "passed", + "asan_ubsan": "passed" + }, + "test-program": { + "native": "passed", + "asan_ubsan": "passed", + "boundary_semantic_comparisons": 3584 + }, + "leak_detection_disabled": false + }, + "unit_binary_sha256": { + "build/test-flow": "63830f166dc326d95e91a30b24bba1f750de05c50b60cf454616b35eef1634dc", + "build/test-program": "cb8e9de15301090fda719144bd0fbbbeb9e6e91a14b2bae8e434df2ac510b527", + "build/sanitized/test-flow": "1b608d5ad31b21180e8a4bce0c0ceee79db281c93ef2e7adf0261ca0b5f96df2", + "build/sanitized/test-program": "4396abe70699580d8c27d74aeb50713a9452e6f4060289b97e2d48d5c478666d" + }, + "integration_scenarios_each": 22, + "original_storage_scenarios": 16, + "authored_connection_scenarios": 6, + "source_snapshots": "sources/", + "source_hashes_equal": true, + "source_snapshots_match_records": true, + "final_binaries_match_records": true, + "prepared_connections_equal": true, + "native_dynamic_dependencies_ldd": [ + "\tlinux-vdso.so.1 (0x00007ffee8528000)", + "\tlibc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x00007ffee82e0000)", + "\t/lib/ld-linux-aarch64.so.1 (0x00007ffee84f0000)" + ], + "limitations": [ + "Fixed byte-range authoring profile, not the general application language or graph runtime", + "One registered storage effect per handler; constant ranges, no dynamic branches or feedback loops", + "The native connection authoring parser is separate from the existing UI/scalar parser", + "runtime_view_steps is a structural description, not a measured instruction count", + "Logical payload bounds exclude mmap page rounding, page cache, disk retention, sockets and client allocations", + "Selecting input bytes for a reply retains and charges the complete original receive allocation", + "Synchronous storage calls delay other connections and timeout enforcement", + "Linux AArch64 only; macOS and real power loss have not been tested", + "Unpublished files left by crashes have no automatic recovery/collection policy" + ] +} diff --git a/prototypes/connections/results/2026-09-24-async-effects/async-core.json b/prototypes/connections/results/2026-09-24-async-effects/async-core.json new file mode 100644 index 0000000..d0932f0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-effects/async-core.json @@ -0,0 +1,55 @@ +{ + "passed": true, + "failures": [], + "budget": { + "profile": "selected-owner-thread-async-effects", + "max_text_bytes": 8192, + "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, + "effect_bytes": 128 + }, + "archive_members": [ + "runtime-async.o", + "evaluate.o" + ], + "external_symbols_allowed": [ + "memcpy", + "memmove", + "memset", + "strlen" + ], + "scope": "Selected owner-thread completion extension. Host I/O and worker stacks are excluded. The default execution core and fixed document packages do not link this profile." + }, + "layout": { + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8, + "effect_bytes": 128 + }, + "sections": { + "text": 7312, + "data": 0, + "bss": 0 + }, + "members": [ + "runtime-async.o", + "evaluate.o" + ], + "external_symbols": [ + "memcpy", + "strlen" + ], + "size_output": " text\t data\t bss\t dec\t hex\tfilename\n 6392\t 0\t 0\t 6392\t 18f8\truntime-async.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution-async.a)\n 920\t 0\t 0\t 920\t 398\tevaluate.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution-async.a)\n", + "archive_sha256": "afa5efe941031832232aec8a64e0727b93927995edd1d395f8feeb7fa4f9611d", + "layout_binary_sha256": "71866a57a3a2af37a8e3d4f2f97669c482b11b3a408a1d918e5de068dab9e34a", + "budget_sha256": "496e34596e24418efb46e460126b7d67492137207be988525abda1397d70c539", + "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/connections/results/2026-09-24-async-effects/default-core.json b/prototypes/connections/results/2026-09-24-async-effects/default-core.json new file mode 100644 index 0000000..b92c015 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-effects/default-core.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": "2de4e84fe2c104368339145ffa7ade5c31a9fd0e2a4ff00ff5edd469759b3c79", + "layout_binary_sha256": "db112a9cfd8deed80b9bfbff7628958fdb290cadab1efc7223d8d15ef4c4c389", + "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/connections/results/2026-09-24-async-effects/flow.json b/prototypes/connections/results/2026-09-24-async-effects/flow.json new file mode 100644 index 0000000..75447c2 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-effects/flow.json @@ -0,0 +1 @@ +{"async_effects":"authored-document-worker-overlap-release-forward-budget-recovery"} diff --git a/prototypes/connections/results/2026-09-24-async-effects/sanitized.json b/prototypes/connections/results/2026-09-24-async-effects/sanitized.json new file mode 100644 index 0000000..75447c2 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-effects/sanitized.json @@ -0,0 +1 @@ +{"async_effects":"authored-document-worker-overlap-release-forward-budget-recovery"} diff --git a/prototypes/connections/results/2026-09-24-async-http/full-check.json b/prototypes/connections/results/2026-09-24-async-http/full-check.json new file mode 100644 index 0000000..55e7539 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-http/full-check.json @@ -0,0 +1,614 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 114, + "server_structure_bytes": 43224 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 43224 + } + ], + "storage": "immutable-byte-extents-v1", + "editor_sha256": "4b9f3e1882d010784d04673a231fdf3ec47b8823527e74f049d5ee3401e6fc60", + "binary_sha256": "08a8283c573fd6512b13242ec8a97286be913db6843ccf971a0fe01dd93eb989", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "e8c84d688ba078e909c57d56c882c963ad5431afe10051bdec84486d3e155b54", + "prototypes/connections/runtime.h": "2a9d00a7f6f916041380cef3e3dc30602364b05288f62607b62b3de2be2595d1", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "acab2509cbde4431a2e0d9e18ad0e917f8f827b7eab46acb3d81afef442b3655", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "f39a7c411afa80812aec6e7a7250cdc833e97cffef8d0cce25d960fe435784f6", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "31933e9d35521d2e9ae0efb1ff21fb4c9a3ab0e3e02444556ebb6db183155b0d", + "prototypes/connections/core-layout.c": "e3123bdf74edb99556b05d7cd3a36b6119ec30829541b0ddba2e23e5900b089d", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-async-http/gated.json b/prototypes/connections/results/2026-09-24-async-http/gated.json new file mode 100644 index 0000000..a4dd464 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-http/gated.json @@ -0,0 +1,38 @@ +{ + "async_http": "reactor-progress-deadline-recovery-draining-shutdown", + "deadline_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 3, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 1, + "releases": 2, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 10, + "server_structure_bytes": 43224 + }, + "shutdown_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 5, + "server_structure_bytes": 43224 + }, + "gated_test_binary_sha256": "aaa9c62fbc1533444467d61ad9a5fe3443b4e37939a3a6aa23301f5e19f5f7b1" +} diff --git a/prototypes/connections/results/2026-09-24-async-http/profile.json b/prototypes/connections/results/2026-09-24-async-http/profile.json new file mode 100644 index 0000000..6599363 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-http/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-one-worker-document-http", + "scope": "Optional one-worker HTTP host on the recorded Linux AArch64/GCC 14 toolchain. Section limits exclude the 128 KiB worker stack, libc, heap, mapped files and kernel buffers. Default CLI and HTTP do not link this option. Inspect process RSS and VM separately.", + "budget_sha256": "4c851b44ad47629459b78de3bdc8ba984f45cc799c7d4933d3edbcbaa12f21e8", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-connections-async": { + "sections": { + "text": 47897, + "data": 1180, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "08a8283c573fd6512b13242ec8a97286be913db6843ccf971a0fe01dd93eb989" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-async-http/sanitized.json b/prototypes/connections/results/2026-09-24-async-http/sanitized.json new file mode 100644 index 0000000..5dc5c69 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-http/sanitized.json @@ -0,0 +1,38 @@ +{ + "async_http": "reactor-progress-deadline-recovery-draining-shutdown", + "deadline_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 3, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 1, + "releases": 2, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 10, + "server_structure_bytes": 43224 + }, + "shutdown_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 5, + "server_structure_bytes": 43224 + }, + "gated_test_binary_sha256": "f2d3e9abaaa2a389baff6c5bcfdb760ede69ff7e05aea479192b4f0c94ab05cf" +} diff --git a/prototypes/connections/results/2026-09-24-async-http/service-scale-memory.json b/prototypes/connections/results/2026-09-24-async-http/service-scale-memory.json new file mode 100644 index 0000000..5507b88 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-http/service-scale-memory.json @@ -0,0 +1,100 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "584b85fce5833d6a1eb5919a6f79c0648200e4ab3f65f2e90d008ea88db3be51", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1504, + "peak_rss_kib": 1696, + "idle_vm_kib": 2448, + "peak_vm_kib": 2512, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49184, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 449903, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "f49ad1538f8ebeecc65511db052a7610c966d33992c5eb076bd36124680aebd1", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1536, + "peak_rss_kib": 1712, + "idle_vm_kib": 2464, + "peak_vm_kib": 2528, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 41001, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 481696, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + }, + "source_async": { + "binary_sha256": "08a8283c573fd6512b13242ec8a97286be913db6843ccf971a0fe01dd93eb989", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1536, + "peak_rss_kib": 1712, + "idle_vm_kib": 2656, + "peak_vm_kib": 2736, + "idle_threads": 2, + "peak_threads": 2, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 57368, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 226974, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 43224 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-async-read-write/full-check.json b/prototypes/connections/results/2026-09-24-async-read-write/full-check.json new file mode 100644 index 0000000..914e9cd --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-read-write/full-check.json @@ -0,0 +1,614 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 11, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115421, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 54, + "borrowed_output_bytes": 57, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 9, + "borrowed_output_bytes": 1, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 16, + "borrowed_output_bytes": 113, + "server_structure_bytes": 43320 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 43320 + } + ], + "storage": "immutable-byte-extents-v1", + "editor_sha256": "4b9f3e1882d010784d04673a231fdf3ec47b8823527e74f049d5ee3401e6fc60", + "binary_sha256": "63211eebd1952dbc9f31bc2eee76c1c661df9bce14e3932b4e15e52fdae5d763", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "e8c84d688ba078e909c57d56c882c963ad5431afe10051bdec84486d3e155b54", + "prototypes/connections/runtime.h": "2a9d00a7f6f916041380cef3e3dc30602364b05288f62607b62b3de2be2595d1", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "e8632667de6feaf4d495f87abb159775650e3e4eaf6713b9dfdc5f82807c2bf9", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "f39a7c411afa80812aec6e7a7250cdc833e97cffef8d0cce25d960fe435784f6", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "f9a0c9fa7d0f40d6d72bc72a429dd36e136e27207aa6b0c01df9505d82189b6d", + "prototypes/connections/core-layout.c": "e3123bdf74edb99556b05d7cd3a36b6119ec30829541b0ddba2e23e5900b089d", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-async-read-write/gated.json b/prototypes/connections/results/2026-09-24-async-read-write/gated.json new file mode 100644 index 0000000..04d5e53 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-read-write/gated.json @@ -0,0 +1,53 @@ +{ + "async_http": "snapshot-read-write-overlap-deadline-recovery-draining-shutdown", + "before_publication": { + "status": 200, + "etag": "\"1\"", + "body": "old" + }, + "after_publication": { + "status": 200, + "etag": "\"2\"", + "body": "oldafter" + }, + "shutdown_restore": { + "revision": "2", + "body": "oldafter" + }, + "deadline_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 1, + "releases": 3, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43320 + }, + "shutdown_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 5, + "server_structure_bytes": 43320 + }, + "test_source_sha256": "f38c9d3d784676ce815c276fbffc10e00f767aaa803793d8db311c19ef3b9085", + "gated_test_binary_sha256": "e1764fd031a24851d3fab665df1538fa947e2bf653accaa528bb60cd6daf7105" +} diff --git a/prototypes/connections/results/2026-09-24-async-read-write/profile.json b/prototypes/connections/results/2026-09-24-async-read-write/profile.json new file mode 100644 index 0000000..dee3dc7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-read-write/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-read-write-document-http", + "scope": "Optional one-read/one-write-worker HTTP host on the recorded Linux AArch64/GCC 14 toolchain. Section limits exclude the two 128 KiB worker stacks, libc, heap, mapped files and kernel buffers. Default CLI and HTTP do not link this option. Inspect process RSS and VM separately.", + "budget_sha256": "acd82386572fc2f874ca2fee5f4eed68ee145cf990d7315a9f1aff06daf51995", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-connections-async": { + "sections": { + "text": 48401, + "data": 1180, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "63211eebd1952dbc9f31bc2eee76c1c661df9bce14e3932b4e15e52fdae5d763" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-async-read-write/sanitized.json b/prototypes/connections/results/2026-09-24-async-read-write/sanitized.json new file mode 100644 index 0000000..f867695 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-read-write/sanitized.json @@ -0,0 +1,53 @@ +{ + "async_http": "snapshot-read-write-overlap-deadline-recovery-draining-shutdown", + "before_publication": { + "status": 200, + "etag": "\"1\"", + "body": "old" + }, + "after_publication": { + "status": 200, + "etag": "\"2\"", + "body": "oldafter" + }, + "shutdown_restore": { + "revision": "2", + "body": "oldafter" + }, + "deadline_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 1, + "releases": 3, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43320 + }, + "shutdown_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 5, + "server_structure_bytes": 43320 + }, + "test_source_sha256": "f38c9d3d784676ce815c276fbffc10e00f767aaa803793d8db311c19ef3b9085", + "gated_test_binary_sha256": "b3b89925aabe1f6edd3a782d99c5adb1dff68bf727066fb00372a16d00d92e53" +} diff --git a/prototypes/connections/results/2026-09-24-async-read-write/service-scale.json b/prototypes/connections/results/2026-09-24-async-read-write/service-scale.json new file mode 100644 index 0000000..eb73971 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-async-read-write/service-scale.json @@ -0,0 +1,100 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "a16cc0c8790cfc6745c6d78f09f527dcd5d9e749669fe4ad9b0d817f83f6cfee", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1504, + "peak_rss_kib": 1680, + "idle_vm_kib": 2448, + "peak_vm_kib": 2528, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49201, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 899599, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "64959669514855565554abb4622dc2bee34a0792485f19bd6466eb63a68e9688", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1520, + "peak_rss_kib": 1696, + "idle_vm_kib": 2464, + "peak_vm_kib": 2528, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49201, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 465705, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + }, + "source_async": { + "binary_sha256": "63211eebd1952dbc9f31bc2eee76c1c661df9bce14e3932b4e15e52fdae5d763", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1552, + "peak_rss_kib": 1728, + "idle_vm_kib": 2848, + "peak_vm_kib": 2928, + "idle_threads": 3, + "peak_threads": 3, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49192, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 238875, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 43320 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/policy-peer.json b/prototypes/connections/results/2026-09-24-atomic-policy/policy-peer.json new file mode 100644 index 0000000..e93d7b0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/policy-peer.json @@ -0,0 +1,12 @@ +{ + "checks": [ + "authored final size is checked against the locked document version", + "a rejected growth leaves revision and bytes unchanged", + "stale revision reports conflict before the policy decision", + "source CLI and HTTP share a policy and committed document", + "source policy changes behavior after the HTTP host restarts", + "fixed CLI and HTTP retain their compiled policy after source edits", + "source and fixed terminal editors preserve unsaved drafts on policy denial", + "authenticated peer shares the policy and current stored version" + ] +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/policy.json b/prototypes/connections/results/2026-09-24-atomic-policy/policy.json new file mode 100644 index 0000000..4e85685 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/policy.json @@ -0,0 +1,11 @@ +{ + "checks": [ + "authored final size is checked against the locked document version", + "a rejected growth leaves revision and bytes unchanged", + "stale revision reports conflict before the policy decision", + "source CLI and HTTP share a policy and committed document", + "source policy changes behavior after the HTTP host restarts", + "fixed CLI and HTTP retain their compiled policy after source edits", + "source and fixed terminal editors preserve unsaved drafts on policy denial" + ] +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..6383ee8 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..a9977dd --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "c82d4815c55e44d1293204d3151aea16fd1c2a439e342337a7a04ef191787bb6", + "archive_bytes": 12966, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..013ccec Binary files /dev/null and b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..98c8fec --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "9dee9779e621d46c6099367cdca8dc65942cf7eb32480a06b79e577be8f7e1a9", + "archive_bytes": 32144, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..b82c68c Binary files /dev/null and b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..bb760ea --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-linux-aarch64.tar.gz.check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "3056f56b80c89e9b92755b36f4ea22a579c336ab48a32a22b9fa619eef810dd0", + "archive_bytes": 22862, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..73fdfe3 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..6b54198 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "308766d5f31ac289ce65f0af849add3564d90a3c42148b46bc6a1db02e64a564", + "archive_bytes": 51398, + "selected_hosts": [ + "pure-edit", + "pure-edit-tui" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "relocated source-loaded TUI and CLI share UTF-8, large and many-document paths" + ] +} diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-atomic-policy/pure-source-preview.tar.gz new file mode 100644 index 0000000..0da15d4 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-atomic-policy/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-atomic-policy/source-package-check.json b/prototypes/connections/results/2026-09-24-atomic-policy/source-package-check.json new file mode 100644 index 0000000..71cd254 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-atomic-policy/source-package-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "e68c5336dd0d4c20b5f6eed2d3fc132cec64419498fc8028b065774770f8f47c", + "archive_bytes": 337937, + "source_files": 173, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean native TLS client and both server profiles pass authenticated document work", + "clean selected page store exports mixed revisions to the default flat store", + "clean one-MiB document fans out to two same-process Pure domains with independent conflict and release", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/cli-check.json b/prototypes/connections/results/2026-09-24-compact-runtime/cli-check.json new file mode 100644 index 0000000..3e93243 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/cli-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "c69d3934b3567338c5a9c607b2a7f5df19963f3b45d6172d5538e8444125437b", + "archive_bytes": 12885, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/cli-tui-check.json b/prototypes/connections/results/2026-09-24-compact-runtime/cli-tui-check.json new file mode 100644 index 0000000..5d70484 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/cli-tui-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "5d554fc24ce273306efe77be9e137424cac08f16c067f4691206a33030af844b", + "archive_bytes": 31971, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/cli-tui.tar.gz b/prototypes/connections/results/2026-09-24-compact-runtime/cli-tui.tar.gz new file mode 100644 index 0000000..1846344 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-compact-runtime/cli-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/cli.tar.gz b/prototypes/connections/results/2026-09-24-compact-runtime/cli.tar.gz new file mode 100644 index 0000000..b216469 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-compact-runtime/cli.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/core.json b/prototypes/connections/results/2026-09-24-compact-runtime/core.json new file mode 100644 index 0000000..63eb227 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/core.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": 128, + "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": "Compact release object sections and C bookkeeping, without opt-in PURE_RUNTIME_METRICS, 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": 128, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8 + }, + "sections": { + "text": 5908, + "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 4988\t 0\t 0\t 4988\t 137c\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": "bb5750a47f6f8ac4307dd633d836460d6045347880a6c53df9669e675e49929e", + "layout_binary_sha256": "04c008fb7e0b1ecc8435bce6fbc4478de6180a54da6538d8742acb9b51992fe3", + "budget_sha256": "e418419db6231305342ea8780149948e0e41e28b3afb937268e80b4695d3d5cd", + "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/connections/results/2026-09-24-compact-runtime/default-profile.json b/prototypes/connections/results/2026-09-24-compact-runtime/default-profile.json new file mode 100644 index 0000000..b12bfac --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/default-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35783, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "e1d49cb048b3e9c25183ab0a4cf30c55486f696c13d20a85d69e0ddc04e040c7" + }, + "pure-connections": { + "sections": { + "text": 43938, + "data": 1068, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "662e2e9dfa6b77d06781d36912b9c2d79427a95e9ec14bc174454fb3f54deecb" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/fixed-memory.json b/prototypes/connections/results/2026-09-24-compact-runtime/fixed-memory.json new file mode 100644 index 0000000..cddbe7b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/fixed-memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, the embedded prepared program, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1104, + "one_mib_edit_rss_kib": 2112, + "one_mib_show_rss_kib": 1072 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "df77304f4ab30eaee3ef41f8ac1f353d8faebcba163cfd76f0b18973e6dc16f5", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/fixed-profile.json b/prototypes/connections/results/2026-09-24-compact-runtime/fixed-profile.json new file mode 100644 index 0000000..ff98463 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "df77304f4ab30eaee3ef41f8ac1f353d8faebcba163cfd76f0b18973e6dc16f5" + }, + "pure-connections-fixed": { + "sections": { + "text": 26549, + "data": 1028, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "9cc73e8f8da266083e8da0628bb30441f6fa9f73317d75e65df8b5231007c746" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/source-cli-check.json b/prototypes/connections/results/2026-09-24-compact-runtime/source-cli-check.json new file mode 100644 index 0000000..78c01d5 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/source-cli-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "0d2785a8893f7cb4b9947c64eb278587f96720926163575c3ec51e106fafedc7", + "archive_bytes": 22454, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/source-cli.tar.gz b/prototypes/connections/results/2026-09-24-compact-runtime/source-cli.tar.gz new file mode 100644 index 0000000..cb6492f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-compact-runtime/source-cli.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/tui-profile.json b/prototypes/connections/results/2026-09-24-compact-runtime/tui-profile.json new file mode 100644 index 0000000..986fd17 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/tui-profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app, including the bounded document browser. The raised text ceiling reviews its measured 2,541-byte addition; default CLI and core ceilings are unchanged. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS, including 1,024-key browsing, is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "4d3097c9ec3c0139cbdd82161a74d610c15d31c6ee5625101410627664f925e7", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 32015, + "data": 1036, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "3118fa11fe6ee6587bb122ae30a0d3c5cf1a8cec3131bb65b11246480c9d3b9e" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-compact-runtime/tui.json b/prototypes/connections/results/2026-09-24-compact-runtime/tui.json new file mode 100644 index 0000000..639ae64 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-compact-runtime/tui.json @@ -0,0 +1,22 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1808, + "one_mib_peak_rss_kib": 3168, + "one_thousand_key_list_peak_rss_kib": 1888, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "3118fa11fe6ee6587bb122ae30a0d3c5cf1a8cec3131bb65b11246480c9d3b9e" +} diff --git a/prototypes/connections/results/2026-09-24-copy-range-source/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-copy-range-source/pure-source-preview.tar.gz new file mode 100644 index 0000000..b0a8e7a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-copy-range-source/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-copy-range-source/source-check-linux-aarch64.json b/prototypes/connections/results/2026-09-24-copy-range-source/source-check-linux-aarch64.json new file mode 100644 index 0000000..aad35c2 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range-source/source-check-linux-aarch64.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "ce48ef8e330cff470b78aec9c9e6c6d8877e4c8c371512f190b91b6036ddc8ba", + "archive_bytes": 324261, + "source_files": 166, + "checks": [ + "every extracted source file matches the manifest", + "clean C17 build and host-neutral behavior tests pass", + "clean fixed and source-loaded native terminal PTY scenarios pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean native TLS client and both server profiles pass authenticated document work", + "clean selected Linux copy path passes app, scale, size, RSS and traced one-MiB splice", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/copy-path.json b/prototypes/connections/results/2026-09-24-copy-range/copy-path.json new file mode 100644 index 0000000..5c714ed --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/copy-path.json @@ -0,0 +1,27 @@ +{ + "profile": "linux-immutable-one-mib-last-byte-splice", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "baseline": { + "binary_sha256": "23ebc0ce1df9c49216511bee658fd5b9e373509e296bd2c2f8e76e6d3081cded", + "result_sha256": "06cb5e32505f66804f6e8c79e3230105ca4769444e1389ee36f6f11fd61f2d3a", + "object_pread_calls": 257, + "copy_file_range_calls": 0, + "copy_file_range_bytes": 0, + "pending_write_calls": 258, + "pending_write_bytes": 1048600, + "one_mib_splice_peak_rss_kib": 1056 + }, + "candidate": { + "binary_sha256": "52c2b828b0cba3564a37e3874f60bec793d1dd7031aff77ba850cee7683977f9", + "result_sha256": "06cb5e32505f66804f6e8c79e3230105ca4769444e1389ee36f6f11fd61f2d3a", + "object_pread_calls": 1, + "copy_file_range_calls": 1, + "copy_file_range_bytes": 1048575, + "pending_write_calls": 2, + "pending_write_bytes": 25, + "one_mib_splice_peak_rss_kib": 1056 + }, + "filesystem": "ext4", + "scratch_parent": "/home/eunhhu/work/pure/prototypes/connections/build", + "scope": "Successful object-file syscalls and child peak RSS; excludes physical device writes, page cache and proof of lower latency." +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/default-core.json b/prototypes/connections/results/2026-09-24-copy-range/default-core.json new file mode 100644 index 0000000..63eb227 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/default-core.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": 128, + "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": "Compact release object sections and C bookkeeping, without opt-in PURE_RUNTIME_METRICS, 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": 128, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8 + }, + "sections": { + "text": 5908, + "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 4988\t 0\t 0\t 4988\t 137c\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": "bb5750a47f6f8ac4307dd633d836460d6045347880a6c53df9669e675e49929e", + "layout_binary_sha256": "04c008fb7e0b1ecc8435bce6fbc4478de6180a54da6538d8742acb9b51992fe3", + "budget_sha256": "e418419db6231305342ea8780149948e0e41e28b3afb937268e80b4695d3d5cd", + "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/connections/results/2026-09-24-copy-range/default-fixed-memory.json b/prototypes/connections/results/2026-09-24-copy-range/default-fixed-memory.json new file mode 100644 index 0000000..6c6d1cd --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/default-fixed-memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, the embedded prepared program, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1104, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1072 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/default-fixed-profile.json b/prototypes/connections/results/2026-09-24-copy-range/default-fixed-profile.json new file mode 100644 index 0000000..297b9b1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/default-fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec" + }, + "pure-connections-fixed": { + "sections": { + "text": 26549, + "data": 1028, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "0f1c24533177357c49270c01070a7376345763393b3914276ca028e52e8589b9" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/default-functional.json b/prototypes/connections/results/2026-09-24-copy-range/default-functional.json new file mode 100644 index 0000000..7e54edf --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/default-functional.json @@ -0,0 +1,636 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 113, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true, + "idle_slot_binding": true + }, + "execution_core_test_sha256": "1e483f9c0525862041e843bba153106d7819052a787b683440d0babee80f8975", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "35554177ec2109f6603d88dd4b4bdccc33497e9f229e486666f224db8e24de9b", + "editor_sha256": "23ebc0ce1df9c49216511bee658fd5b9e373509e296bd2c2f8e76e6d3081cded", + "binary_sha256": "6cce6c7c18d8a24059455a2f6d4aa19c98e503a5b2e7462e2c8d3940f23bd827", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store.c": "0d3df2df40fa9035cd98a1052d4c9fdad298475e9df5d49cfa7d475188fda072", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "7389c1343d6253344c57d4f12bd9c8c472ed5c11d386839a1a2e3ee19f5f66ca", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "1ced6734ee07f3688e5c9cc0a8af639da88a1741dc3e6f65ec109baa2d826e6f", + "prototypes/connections/runtime.h": "e217b7acd94f3572d2780be1d6b13c5d5556227bab93b4f773e7db8b0eee6856", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "8c275bf564a674f0f8232026d0d7612df48f10054af75a43af5ed0bce7653e24", + "prototypes/connections/server.c": "e8632667de6feaf4d495f87abb159775650e3e4eaf6713b9dfdc5f82807c2bf9", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "f39a7c411afa80812aec6e7a7250cdc833e97cffef8d0cce25d960fe435784f6", + "prototypes/connections/test-splice.c": "80a942fe7cc05533df346fe656c0a1f9bbbbb803d72415aa56110f089de18322", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "72d30528cb59a3a8ca8cf8cc414873fe0b26d6093cd8f06bfe9585235ba5befb", + "prototypes/connections/core-layout.c": "e3123bdf74edb99556b05d7cd3a36b6119ec30829541b0ddba2e23e5900b089d", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "e418419db6231305342ea8780149948e0e41e28b3afb937268e80b4695d3d5cd", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/default-memory.json b/prototypes/connections/results/2026-09-24-copy-range/default-memory.json new file mode 100644 index 0000000..0ed783a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/default-memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "source-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "23ebc0ce1df9c49216511bee658fd5b9e373509e296bd2c2f8e76e6d3081cded", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/default-profile.json b/prototypes/connections/results/2026-09-24-copy-range/default-profile.json new file mode 100644 index 0000000..fa1c423 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/default-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35783, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "23ebc0ce1df9c49216511bee658fd5b9e373509e296bd2c2f8e76e6d3081cded" + }, + "pure-connections": { + "sections": { + "text": 43938, + "data": 1068, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "6cce6c7c18d8a24059455a2f6d4aa19c98e503a5b2e7462e2c8d3940f23bd827" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/functional.json b/prototypes/connections/results/2026-09-24-copy-range/functional.json new file mode 100644 index 0000000..e645c99 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/functional.json @@ -0,0 +1,636 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 12, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 113, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true, + "idle_slot_binding": true + }, + "execution_core_test_sha256": "74e4a101d4c44c9aa6b544d6370cee570870386fb15c7444f6f077b2d975af8c", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "893349eba46c9e555ce8452296d4f776020d14310fcd7a55a6e78e1ba4b592fe", + "editor_sha256": "52c2b828b0cba3564a37e3874f60bec793d1dd7031aff77ba850cee7683977f9", + "binary_sha256": "34b501dec69c00aec2c5a4aaad2f6aca57de1b782f96d894a00536a440633c38", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store.c": "0d3df2df40fa9035cd98a1052d4c9fdad298475e9df5d49cfa7d475188fda072", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "7389c1343d6253344c57d4f12bd9c8c472ed5c11d386839a1a2e3ee19f5f66ca", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "1ced6734ee07f3688e5c9cc0a8af639da88a1741dc3e6f65ec109baa2d826e6f", + "prototypes/connections/runtime.h": "e217b7acd94f3572d2780be1d6b13c5d5556227bab93b4f773e7db8b0eee6856", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "8c275bf564a674f0f8232026d0d7612df48f10054af75a43af5ed0bce7653e24", + "prototypes/connections/server.c": "e8632667de6feaf4d495f87abb159775650e3e4eaf6713b9dfdc5f82807c2bf9", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "f39a7c411afa80812aec6e7a7250cdc833e97cffef8d0cce25d960fe435784f6", + "prototypes/connections/test-splice.c": "80a942fe7cc05533df346fe656c0a1f9bbbbb803d72415aa56110f089de18322", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "d4f130cc782730bc712268bbf4f0426bdf138ca03696fa7a32d781efded4f5cb", + "prototypes/connections/core-layout.c": "e3123bdf74edb99556b05d7cd3a36b6119ec30829541b0ddba2e23e5900b089d", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "e418419db6231305342ea8780149948e0e41e28b3afb937268e80b4695d3d5cd", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/memory.json b/prototypes/connections/results/2026-09-24-copy-range/memory.json new file mode 100644 index 0000000..a1bb66d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "source-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "52c2b828b0cba3564a37e3874f60bec793d1dd7031aff77ba850cee7683977f9", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/profile.json b/prototypes/connections/results/2026-09-24-copy-range/profile.json new file mode 100644 index 0000000..9975cfa --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 36052, + "data": 952, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "52c2b828b0cba3564a37e3874f60bec793d1dd7031aff77ba850cee7683977f9" + }, + "pure-connections": { + "sections": { + "text": 44207, + "data": 1076, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "34b501dec69c00aec2c5a4aaad2f6aca57de1b782f96d894a00536a440633c38" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-copy-range/service-scale.json b/prototypes/connections/results/2026-09-24-copy-range/service-scale.json new file mode 100644 index 0000000..6ff4bbb --- /dev/null +++ b/prototypes/connections/results/2026-09-24-copy-range/service-scale.json @@ -0,0 +1,70 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "2ca0c718fb8b38bd2bb6be2a1296c5346a427ca2fa8cf12607d81f81217f085b", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1504, + "peak_rss_kib": 1664, + "idle_vm_kib": 2448, + "peak_vm_kib": 2512, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49185, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 545480, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "34b501dec69c00aec2c5a4aaad2f6aca57de1b782f96d894a00536a440633c38", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1552, + "peak_rss_kib": 1712, + "idle_vm_kib": 2464, + "peak_vm_kib": 2544, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 40992, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 326641, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-document-peer/clean-peer.log b/prototypes/connections/results/2026-09-24-document-peer/clean-peer.log new file mode 100644 index 0000000..ff472a7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/clean-peer.log @@ -0,0 +1,43 @@ +make: Entering directory '/home/eunhhu/work/pure/prototypes/connections' +python3 check_profile.py --build build/checkpoint-clean --budget peer-budget.json +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-document-peer", + "scope": "Review threshold for the explicitly selected authenticated document host on Linux AArch64/GCC 14. Includes linked application code but excludes the shared OpenSSL/libc implementations, process heap, stack, mappings and kernel buffers. Check measured whole-process RSS separately.", + "budget_sha256": "defe8efb4d544cdbf615de4d43d4ceba78fc7f1d958f245731202625052eb2ff", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-document-peer": { + "sections": { + "text": 46878, + "data": 1384, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libcrypto.so.3", + "libssl.so.3" + ], + "sha256": "e0bec4f698ce6b561a6caa8464636317409011e3101c48649cf5585c33ec9d31" + } + } +} +python3 check_document_peer.py --peer build/checkpoint-clean/pure-document-peer --editor build/checkpoint-clean/pure-edit +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7840, + "server_after_large_work_rss_kib": 9488 +} +make: Leaving directory '/home/eunhhu/work/pure/prototypes/connections' diff --git a/prototypes/connections/results/2026-09-24-document-peer/clean-release.log b/prototypes/connections/results/2026-09-24-document-peer/clean-release.log new file mode 100644 index 0000000..db3d23d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/clean-release.log @@ -0,0 +1,737 @@ +make: Entering directory '/home/eunhhu/work/pure/prototypes/connections' +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror core-layout.c -o build/checkpoint-clean/core-layout +python3 check_core.py --archive build/checkpoint-clean/libpure-execution.a --layout build/checkpoint-clean/core-layout +{ + "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/checkpoint-clean/libpure-execution.a)\n 920\t 0\t 0\t 920\t 398\tevaluate.o (ex /home/eunhhu/work/pure/prototypes/connections/build/checkpoint-clean/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" +} +python3 check_profile.py --build build/checkpoint-clean +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35807, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "pure-connections": { + "sections": { + "text": 42761, + "data": 1040, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f" + } + } +} +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror -DPURE_STORE_TEST test-flow.c store.c flow.c -o build/checkpoint-clean/test-flow +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror test-program.c program.c evaluate.c -o build/checkpoint-clean/test-program +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror test-fields.c program.c evaluate.c -o build/checkpoint-clean/test-fields +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror test-runtime.c program.c runtime.c evaluate.c store.c store-port.c -o build/checkpoint-clean/test-runtime +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror -DPURE_STORE_TEST test-splice.c program.c runtime.c evaluate.c store.c store-port.c -o build/checkpoint-clean/test-splice +build/checkpoint-clean/test-flow +{"transfer":"partial-consumption-and-single-release","storage":"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty"} +build/checkpoint-clean/test-program +{"program":"prepare-views-preserve-guards-types-effects-and-atomic-install","boundary_semantic_comparisons":3584} +build/checkpoint-clean/test-fields +{"fields":"named-layouts-unaligned-u32le-tail-dynamic-ranges-aliases-failures-limits","data_boundary_cases":48} +python3 check.py --server build/checkpoint-clean/pure-connections --runtime-test build/checkpoint-clean/test-runtime --splice-test build/checkpoint-clean/test-splice --editor build/checkpoint-clean/pure-edit +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 113, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "07c328c98769d02d544aea2fc5389048a7620c00d9b3ea49b9c7ba45e33984b1", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "0e4ec2c78334af2056af376a6ba9aef7416a34c6d4fbd9082f4abb640e6de28c", + "editor_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "binary_sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "5d08639b5a71572553bda47cbd35562805d601aaa81f20731cd9154768ec025e", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "e1735316769b4ce4d5904b9587ccfa3ebeafa26517c53dba4491191d58c97b8e", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "c0767afe55bdb13e1308414116047ad76d5b105c7b4162d7220c78c1d1a4076e", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} +make: Leaving directory '/home/eunhhu/work/pure/prototypes/connections' diff --git a/prototypes/connections/results/2026-09-24-document-peer/dynamic.txt b/prototypes/connections/results/2026-09-24-document-peer/dynamic.txt new file mode 100644 index 0000000..ff4f898 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/dynamic.txt @@ -0,0 +1,31 @@ + +Dynamic section at offset 0xfda8 contains 28 entries: + Tag Type Name/Value + 0x0000000000000001 (NEEDED) Shared library: [libssl.so.3] + 0x0000000000000001 (NEEDED) Shared library: [libcrypto.so.3] + 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] + 0x000000000000000c (INIT) 0x1c18 + 0x000000000000000d (FINI) 0x92f8 + 0x0000000000000019 (INIT_ARRAY) 0x1fd98 + 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) + 0x000000000000001a (FINI_ARRAY) 0x1fda0 + 0x000000000000001c (FINI_ARRAYSZ) 8 (bytes) + 0x000000006ffffef5 (GNU_HASH) 0x2b0 + 0x0000000000000005 (STRTAB) 0xc48 + 0x0000000000000006 (SYMTAB) 0x2d0 + 0x000000000000000a (STRSZ) 1217 (bytes) + 0x000000000000000b (SYMENT) 24 (bytes) + 0x0000000000000015 (DEBUG) 0x0 + 0x0000000000000003 (PLTGOT) 0x1ffe8 + 0x0000000000000002 (PLTRELSZ) 2256 (bytes) + 0x0000000000000014 (PLTREL) RELA + 0x0000000000000017 (JMPREL) 0x1348 + 0x0000000000000007 (RELA) 0x1258 + 0x0000000000000008 (RELASZ) 240 (bytes) + 0x0000000000000009 (RELAENT) 24 (bytes) + 0x000000006ffffffb (FLAGS_1) Flags: PIE + 0x000000006ffffffe (VERNEED) 0x11d8 + 0x000000006fffffff (VERNEEDNUM) 3 + 0x000000006ffffff0 (VERSYM) 0x110a + 0x000000006ffffff9 (RELACOUNT) 4 + 0x0000000000000000 (NULL) 0x0 diff --git a/prototypes/connections/results/2026-09-24-document-peer/file-sizes.txt b/prototypes/connections/results/2026-09-24-document-peer/file-sizes.txt new file mode 100644 index 0000000..c625025 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/file-sizes.txt @@ -0,0 +1,2 @@ +68312 /tmp/pure-document-peer.stripped +213080 prototypes/connections/build/pure-document-peer diff --git a/prototypes/connections/results/2026-09-24-document-peer/hashes.txt b/prototypes/connections/results/2026-09-24-document-peer/hashes.txt new file mode 100644 index 0000000..0546b43 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/hashes.txt @@ -0,0 +1,4 @@ +7d6f7cac8422a800fa4407e76c29ce21aa0931020ee2f4aa7b0ae646b88aa034 prototypes/connections/document-peer.c +df259073c5e1cd911495b48d4ccd43c8346d955a19a8773964abe58089fbebf9 prototypes/common/tls-link.c +297a5d41b129ad134f5a398069565a1de0793024e6aa483def1dc1e09ebd1443 prototypes/common/tls-link.h +e0bec4f698ce6b561a6caa8464636317409011e3101c48649cf5585c33ec9d31 prototypes/connections/build/pure-document-peer diff --git a/prototypes/connections/results/2026-09-24-document-peer/package-check.json b/prototypes/connections/results/2026-09-24-document-peer/package-check.json new file mode 100644 index 0000000..a6031fc --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/package-check.json @@ -0,0 +1,19 @@ +{ + "archive_sha256": "dc3f7339bf5aa22b11eb5ac4c8b4a547168e0e06e65c8003a545f70a31500081", + "archive_bytes": 316927, + "selected_hosts": [ + "pure-edit", + "pure-connections", + "pure-document-peer", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted C/Python TLS peers share a 1 MiB document and recover", + "extracted GTK window, CLI and HTTP adapter edit the same document" + ], + "peer_idle_rss_kib": 7840, + "peer_after_large_work_rss_kib": 9488 +} diff --git a/prototypes/connections/results/2026-09-24-document-peer/package-hashes.txt b/prototypes/connections/results/2026-09-24-document-peer/package-hashes.txt new file mode 100644 index 0000000..825f3e9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/package-hashes.txt @@ -0,0 +1,3 @@ +dc3f7339bf5aa22b11eb5ac4c8b4a547168e0e06e65c8003a545f70a31500081 dist/pure-document-linux-aarch64.tar.gz +dc3f7339bf5aa22b11eb5ac4c8b4a547168e0e06e65c8003a545f70a31500081 /tmp/pure-document-repro.tar.gz +dc3f7339bf5aa22b11eb5ac4c8b4a547168e0e06e65c8003a545f70a31500081 prototypes/connections/results/2026-09-24-document-peer/pure-document-linux-aarch64.tar.gz diff --git a/prototypes/connections/results/2026-09-24-document-peer/peer-budget.json b/prototypes/connections/results/2026-09-24-document-peer/peer-budget.json new file mode 100644 index 0000000..369a71c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/peer-budget.json @@ -0,0 +1,23 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-document-peer", + "scope": "Review threshold for the explicitly selected authenticated document host on Linux AArch64/GCC 14. Includes linked application code but excludes the shared OpenSSL/libc implementations, process heap, stack, mappings and kernel buffers. Check measured whole-process RSS separately.", + "budget_sha256": "defe8efb4d544cdbf615de4d43d4ceba78fc7f1d958f245731202625052eb2ff", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-document-peer": { + "sections": { + "text": 46878, + "data": 1384, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libcrypto.so.3", + "libssl.so.3" + ], + "sha256": "e0bec4f698ce6b561a6caa8464636317409011e3101c48649cf5585c33ec9d31" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-document-peer/peer-flow.json b/prototypes/connections/results/2026-09-24-document-peer/peer-flow.json new file mode 100644 index 0000000..32f3991 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/peer-flow.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7840, + "server_after_large_work_rss_kib": 9488 +} diff --git a/prototypes/connections/results/2026-09-24-document-peer/peer-sanitized.json b/prototypes/connections/results/2026-09-24-document-peer/peer-sanitized.json new file mode 100644 index 0000000..c1e698c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/peer-sanitized.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 22592, + "server_after_large_work_rss_kib": 31632 +} diff --git a/prototypes/connections/results/2026-09-24-document-peer/pure-document-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-document-peer/pure-document-linux-aarch64.tar.gz new file mode 100644 index 0000000..006e71d Binary files /dev/null and b/prototypes/connections/results/2026-09-24-document-peer/pure-document-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-document-peer/scalar-refactor.json b/prototypes/connections/results/2026-09-24-document-peer/scalar-refactor.json new file mode 100644 index 0000000..a1cf089 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/scalar-refactor.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": 8400, "server_hostname": "pass", "server_idle_rss_kib": 7824} diff --git a/prototypes/connections/results/2026-09-24-document-peer/size.txt b/prototypes/connections/results/2026-09-24-document-peer/size.txt new file mode 100644 index 0000000..a37699e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-document-peer/size.txt @@ -0,0 +1,2 @@ + text data bss dec hex filename + 46878 1384 16 48278 bc96 prototypes/connections/build/pure-document-peer diff --git a/prototypes/connections/results/2026-09-24-fixed-http/backpressure.json b/prototypes/connections/results/2026-09-24-fixed-http/backpressure.json new file mode 100644 index 0000000..5723e36 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/backpressure.json @@ -0,0 +1,61 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa", + "source_sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/final.json b/prototypes/connections/results/2026-09-24-fixed-http/final.json new file mode 100644 index 0000000..1594bfa --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/final.json @@ -0,0 +1,65 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 3983, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_idle_rss_kib": 1520, + "source_idle_rss_kib": 1568, + "fixed_after_large_rss_kib": 1648, + "source_after_large_rss_kib": 1696, + "fixed_sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa", + "source_sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/fixed-profile.json b/prototypes/connections/results/2026-09-24-fixed-http/fixed-profile.json new file mode 100644 index 0000000..3b3afbf --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8" + }, + "pure-connections-fixed": { + "sections": { + "text": 25228, + "data": 1000, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/package.json b/prototypes/connections/results/2026-09-24-fixed-http/package.json new file mode 100644 index 0000000..7bea960 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "dd1c29ca367271c5be88275695886e3d2b038b20565c016dc63bd761ed62c76e", + "archive_bytes": 31733, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/parity.json b/prototypes/connections/results/2026-09-24-fixed-http/parity.json new file mode 100644 index 0000000..7566d43 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/parity.json @@ -0,0 +1,43 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "fixed_sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa", + "source_sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/peak-memory.json b/prototypes/connections/results/2026-09-24-fixed-http/peak-memory.json new file mode 100644 index 0000000..11902b5 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/peak-memory.json @@ -0,0 +1,67 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_idle_rss_kib": 1520, + "source_idle_rss_kib": 1552, + "fixed_after_large_rss_kib": 1648, + "source_after_large_rss_kib": 1680, + "fixed_peak_rss_kib": 2176, + "source_peak_rss_kib": 2096, + "fixed_sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa", + "source_sha256": "f39eaa24cad84a3ba34a5dd4b1c5bd6c23cdf18862ee3bb9bd6ba27cdba3f895" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/pure-document-fixed-http-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-fixed-http/pure-document-fixed-http-linux-aarch64.tar.gz new file mode 100644 index 0000000..c53ece6 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-fixed-http/pure-document-fixed-http-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-fixed-http/source-profile.json b/prototypes/connections/results/2026-09-24-fixed-http/source-profile.json new file mode 100644 index 0000000..50b7913 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/source-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35807, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7" + }, + "pure-connections": { + "sections": { + "text": 43009, + "data": 1040, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "f39eaa24cad84a3ba34a5dd4b1c5bd6c23cdf18862ee3bb9bd6ba27cdba3f895" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-fixed-http/verified.json b/prototypes/connections/results/2026-09-24-fixed-http/verified.json new file mode 100644 index 0000000..93a7ff3 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-http/verified.json @@ -0,0 +1,68 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_idle_rss_kib": 1520, + "source_idle_rss_kib": 1552, + "fixed_after_large_rss_kib": 1648, + "source_after_large_rss_kib": 1680, + "fixed_peak_rss_kib": 2128, + "source_peak_rss_kib": 1680, + "fixed_peak_rss_limit_kib": 4096, + "fixed_sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa", + "source_sha256": "f39eaa24cad84a3ba34a5dd4b1c5bd6c23cdf18862ee3bb9bd6ba27cdba3f895" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/memory-final.json b/prototypes/connections/results/2026-09-24-fixed-program/memory-final.json new file mode 100644 index 0000000..a85054d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/memory-final.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, the embedded prepared program, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1104, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1088 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "00f0316cb318cf168d4437080642fe1a6db013e7d4ad82be80e57ad9e12a0a3b", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/memory-verified.json b/prototypes/connections/results/2026-09-24-fixed-program/memory-verified.json new file mode 100644 index 0000000..7ccf6ea --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/memory-verified.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, the embedded prepared program, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1104, + "one_mib_edit_rss_kib": 2112, + "one_mib_show_rss_kib": 1088 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/memory.json b/prototypes/connections/results/2026-09-24-fixed-program/memory.json new file mode 100644 index 0000000..a4474ff --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1088, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1104 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "00f0316cb318cf168d4437080642fe1a6db013e7d4ad82be80e57ad9e12a0a3b", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/package-final.json b/prototypes/connections/results/2026-09-24-fixed-program/package-final.json new file mode 100644 index 0000000..385f64a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/package-final.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "84aaf54f6471ddf5cb6fe735c2c3621baf567e186e7252f87e2930024d89ec58", + "archive_bytes": 14385, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/package.json b/prototypes/connections/results/2026-09-24-fixed-program/package.json new file mode 100644 index 0000000..6671dd5 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/package.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "5514e75fa29a9e2375e6ab5069a4b22f275d447725e63cb8b5c7245a9c966d7c", + "archive_bytes": 14386, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/parity-final-v2.json b/prototypes/connections/results/2026-09-24-fixed-program/parity-final-v2.json new file mode 100644 index 0000000..73c6a66 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/parity-final-v2.json @@ -0,0 +1,12 @@ +{ + "profile": "build-time-prepared-native-document-runner", + "editor_create_edit_conflict_large_restore": "pass", + "dynamic_selection_and_invalid_range": "pass", + "nested_read_and_required_extent": "pass", + "invalid_compilation_preserves_previous_plan": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8", + "source_sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7", + "selection_sha256": "7d3394586fc9837a11a01bdae3aeeba2356b3abab8518e84545ede2ec1a6ab1c", + "excerpt_sha256": "b83a1fe42d45574ddf5941fdac2b759754d44da5705e6f33e5a45dabf7fd7205" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/parity-final.json b/prototypes/connections/results/2026-09-24-fixed-program/parity-final.json new file mode 100644 index 0000000..6aa3d12 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/parity-final.json @@ -0,0 +1,11 @@ +{ + "profile": "build-time-prepared-native-document-runner", + "editor_create_edit_conflict_large_restore": "pass", + "dynamic_selection_and_invalid_range": "pass", + "nested_read_and_required_extent": "pass", + "invalid_compilation_preserves_previous_plan": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_sha256": "00f0316cb318cf168d4437080642fe1a6db013e7d4ad82be80e57ad9e12a0a3b", + "source_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "selection_sha256": "836f883efb9dc86218c9606292b6c37241cc98277ce3ece03597795e076427a2" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/parity-reconfigured.json b/prototypes/connections/results/2026-09-24-fixed-program/parity-reconfigured.json new file mode 100644 index 0000000..561a923 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/parity-reconfigured.json @@ -0,0 +1,13 @@ +{ + "profile": "build-time-prepared-native-document-runner", + "editor_create_edit_conflict_large_restore": "pass", + "dynamic_selection_and_invalid_range": "pass", + "nested_read_and_required_extent": "pass", + "invalid_compilation_preserves_previous_plan": "pass", + "same_directory_program_switch_rebuilds": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8", + "source_sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7", + "selection_sha256": "7d3394586fc9837a11a01bdae3aeeba2356b3abab8518e84545ede2ec1a6ab1c", + "excerpt_sha256": "b83a1fe42d45574ddf5941fdac2b759754d44da5705e6f33e5a45dabf7fd7205" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/parity-release.json b/prototypes/connections/results/2026-09-24-fixed-program/parity-release.json new file mode 100644 index 0000000..994b69d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/parity-release.json @@ -0,0 +1,14 @@ +{ + "profile": "build-time-prepared-native-document-runner", + "editor_create_edit_conflict_large_restore": "pass", + "dynamic_selection_and_invalid_range": "pass", + "nested_read_and_required_extent": "pass", + "invalid_compilation_preserves_previous_plan": "pass", + "compiler_rejects_source_as_output": "pass", + "same_directory_program_switch_rebuilds": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8", + "source_sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7", + "selection_sha256": "7d3394586fc9837a11a01bdae3aeeba2356b3abab8518e84545ede2ec1a6ab1c", + "excerpt_sha256": "b83a1fe42d45574ddf5941fdac2b759754d44da5705e6f33e5a45dabf7fd7205" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/parity-verified.json b/prototypes/connections/results/2026-09-24-fixed-program/parity-verified.json new file mode 100644 index 0000000..1350cfe --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/parity-verified.json @@ -0,0 +1,12 @@ +{ + "profile": "build-time-prepared-native-document-runner", + "editor_create_edit_conflict_large_restore": "pass", + "dynamic_selection_and_invalid_range": "pass", + "nested_read_and_required_extent": "pass", + "invalid_compilation_preserves_previous_plan": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_sha256": "00f0316cb318cf168d4437080642fe1a6db013e7d4ad82be80e57ad9e12a0a3b", + "source_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "selection_sha256": "836f883efb9dc86218c9606292b6c37241cc98277ce3ece03597795e076427a2", + "excerpt_sha256": "221543dd8cee08b19c862d11d7a7f9c9c55d974964fb8fc88bf63792d96e14b9" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/parity.json b/prototypes/connections/results/2026-09-24-fixed-program/parity.json new file mode 100644 index 0000000..76e1469 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/parity.json @@ -0,0 +1,10 @@ +{ + "profile": "build-time-prepared-native-document-runner", + "editor_create_edit_conflict_large_restore": "pass", + "dynamic_selection_and_invalid_range": "pass", + "invalid_compilation_preserves_previous_plan": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_sha256": "00f0316cb318cf168d4437080642fe1a6db013e7d4ad82be80e57ad9e12a0a3b", + "source_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "selection_sha256": "836f883efb9dc86218c9606292b6c37241cc98277ce3ece03597795e076427a2" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/profile-final.json b/prototypes/connections/results/2026-09-24-fixed-program/profile-final.json new file mode 100644 index 0000000..e6e530d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/profile-final.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked document runner with one build-time prepared app. The source parser and compiler are separate build tools. This 20 KiB text ceiling applies only to the recorded Linux AArch64/GCC 14 toolchain and excludes libc, heap, stack, and storage files.", + "budget_sha256": "e6f4e727172d1d75bf109f426e169c98fa635c9cc4757ffd3871035593aa00d8", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/profile.json b/prototypes/connections/results/2026-09-24-fixed-program/profile.json new file mode 100644 index 0000000..771271e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked document runner with one build-time prepared app. The source parser and compiler are separate build tools. This 20 KiB text ceiling applies only to the recorded Linux AArch64/GCC 14 toolchain and excludes libc, heap, stack, and storage files.", + "budget_sha256": "e6f4e727172d1d75bf109f426e169c98fa635c9cc4757ffd3871035593aa00d8", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "00f0316cb318cf168d4437080642fe1a6db013e7d4ad82be80e57ad9e12a0a3b" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-final-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-final-linux-aarch64.tar.gz new file mode 100644 index 0000000..511d53f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-final-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-linux-aarch64.tar.gz new file mode 100644 index 0000000..ea09bcb Binary files /dev/null and b/prototypes/connections/results/2026-09-24-fixed-program/pure-document-fixed-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-fixed-program/window-regression.json b/prototypes/connections/results/2026-09-24-fixed-program/window-regression.json new file mode 100644 index 0000000..44483d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-program/window-regression.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_line_width": 80, "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [47328, 48048], "large_window_rss_kib": 44112, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui-final/package.json b/prototypes/connections/results/2026-09-24-fixed-tui-final/package.json new file mode 100644 index 0000000..86651ee --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui-final/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "3c5aa21f36a410bcef5b42c4928b2b24e35a55f7b6d5e62d322b749b48512db6", + "archive_bytes": 33654, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui-final/profile.json b/prototypes/connections/results/2026-09-24-fixed-tui-final/profile.json new file mode 100644 index 0000000..52ebb29 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui-final/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "e378cb302b2a4bcd2ee674af048116f139b214b59f24244d6c825bc71a8e8a31", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 28226, + "data": 988, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "dd6fb1f48a0212b59c4b997bd72ce099ad53f0b2d0d63cdebbc5264fc9bf23f5" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui-final/pty-flow.json b/prototypes/connections/results/2026-09-24-fixed-tui-final/pty-flow.json new file mode 100644 index 0000000..8e134d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui-final/pty-flow.json @@ -0,0 +1,16 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "UTF-8 codepoint movement and edit", + "resize redraw", + "external revision conflict preserves draft", + "one MiB document edit and fresh CLI restore", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1744, + "one_mib_peak_rss_kib": 3168, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "dd6fb1f48a0212b59c4b997bd72ce099ad53f0b2d0d63cdebbc5264fc9bf23f5" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui-final/pty-sanitized.json b/prototypes/connections/results/2026-09-24-fixed-tui-final/pty-sanitized.json new file mode 100644 index 0000000..a5ca7da --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui-final/pty-sanitized.json @@ -0,0 +1,16 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "UTF-8 codepoint movement and edit", + "resize redraw", + "external revision conflict preserves draft", + "one MiB document edit and fresh CLI restore", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 9840, + "one_mib_peak_rss_kib": 11712, + "one_mib_peak_rss_limit_kib": 0, + "binary_sha256": "746ebdfa531accd578ed04132a6d4155a0c8cf44842f30c46cc49ef804f19615" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui-final/pure-document-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-fixed-tui-final/pure-document-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..596d28b Binary files /dev/null and b/prototypes/connections/results/2026-09-24-fixed-tui-final/pure-document-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/package-cursor.json b/prototypes/connections/results/2026-09-24-fixed-tui/package-cursor.json new file mode 100644 index 0000000..ae2716d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/package-cursor.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "0e7d03741e17cc2f39db95aa28e52b8fdd02a54b68c8e978df06407cc241c2bb", + "archive_bytes": 33625, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/package.json b/prototypes/connections/results/2026-09-24-fixed-tui/package.json new file mode 100644 index 0000000..ae2716d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "0e7d03741e17cc2f39db95aa28e52b8fdd02a54b68c8e978df06407cc241c2bb", + "archive_bytes": 33625, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/profile.json b/prototypes/connections/results/2026-09-24-fixed-tui/profile.json new file mode 100644 index 0000000..acc451b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "e378cb302b2a4bcd2ee674af048116f139b214b59f24244d6c825bc71a8e8a31", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 28098, + "data": 988, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "ae3f8ff479899929cb108dd3435750aa24b8f793744a9e7794315974182ad1f7" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-flow.json b/prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-flow.json new file mode 100644 index 0000000..c85112d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-flow.json @@ -0,0 +1,16 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "UTF-8 codepoint movement and edit", + "resize redraw", + "external revision conflict preserves draft", + "one MiB document edit and fresh CLI restore", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1744, + "one_mib_peak_rss_kib": 3168, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "ae3f8ff479899929cb108dd3435750aa24b8f793744a9e7794315974182ad1f7" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-sanitized.json b/prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-sanitized.json new file mode 100644 index 0000000..7852c5d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/pty-cursor-sanitized.json @@ -0,0 +1,16 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "UTF-8 codepoint movement and edit", + "resize redraw", + "external revision conflict preserves draft", + "one MiB document edit and fresh CLI restore", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 9808, + "one_mib_peak_rss_kib": 11760, + "one_mib_peak_rss_limit_kib": 0, + "binary_sha256": "71eeef473231fe7b693022fad39fe3b9b90e89913bbc252a2b64e6d087b1ebf6" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/pty-flow.json b/prototypes/connections/results/2026-09-24-fixed-tui/pty-flow.json new file mode 100644 index 0000000..50f5cfa --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/pty-flow.json @@ -0,0 +1,16 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "create and save through terminal", + "open another key", + "UTF-8 edit and save", + "resize redraw", + "external revision conflict preserves draft", + "one MiB document edit and fresh CLI restore", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1744, + "one_mib_peak_rss_kib": 2784, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "ae3f8ff479899929cb108dd3435750aa24b8f793744a9e7794315974182ad1f7" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/pty-sanitized.json b/prototypes/connections/results/2026-09-24-fixed-tui/pty-sanitized.json new file mode 100644 index 0000000..443e9cd --- /dev/null +++ b/prototypes/connections/results/2026-09-24-fixed-tui/pty-sanitized.json @@ -0,0 +1,16 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "create and save through terminal", + "open another key", + "UTF-8 edit and save", + "resize redraw", + "external revision conflict preserves draft", + "one MiB document edit and fresh CLI restore", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 9776, + "one_mib_peak_rss_kib": 11712, + "one_mib_peak_rss_limit_kib": 0, + "binary_sha256": "71eeef473231fe7b693022fad39fe3b9b90e89913bbc252a2b64e6d087b1ebf6" +} diff --git a/prototypes/connections/results/2026-09-24-fixed-tui/pure-document-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-fixed-tui/pure-document-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..05a8713 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-fixed-tui/pure-document-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-flat-export-source/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-flat-export-source/pure-source-preview.tar.gz new file mode 100644 index 0000000..096ffe4 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-flat-export-source/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-flat-export-source/source-check-linux-aarch64.json b/prototypes/connections/results/2026-09-24-flat-export-source/source-check-linux-aarch64.json new file mode 100644 index 0000000..854f2d0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-flat-export-source/source-check-linux-aarch64.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "16126434bdbef5b75210639cf67f5e27711aa7c540ba1ca6d3949dd45009df9d", + "archive_bytes": 329118, + "source_files": 168, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean selected page store exports mixed revisions to the default flat store", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-flat-export/export-check.json b/prototypes/connections/results/2026-09-24-flat-export/export-check.json new file mode 100644 index 0000000..c64ad9e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-flat-export/export-check.json @@ -0,0 +1,27 @@ +{ + "objects": 5, + "logical_bytes": 1081346, + "document_digest": "efaa71ebcdaf493723b82bc98532aba3fd5beace476f8b9f61f93706bddf850b", + "paged_reader_sha256": "f29440fcc167d1202b226f0faee450cb299989c8977277e062a330587cec5e39", + "flat_reader_sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec", + "passed": true, + "extra_documents": 0, + "source_revisions": { + "empty": 1, + "small": 1, + "boundary": 1, + "large": 1, + "megabyte": 66 + }, + "readback_sha256": { + "empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "small": "4b68ab3847feda7d6c62c1fbcbeebfa35eab7351ed5e78f4ddadea5df64b8015", + "boundary": "1bd4db450abc8914c2fac721cace2704ff4c16028e6d07293154dad289835694", + "large": "b10b6985790c800b653eeb8e979f9c1551293ee9c293cbca1c4741df09cc8284", + "megabyte": "3cacf45be36c19ea18974a474bd2c668f24622b9132ab99294b3778ba0124094" + }, + "flat_edit_after_export": "revision 67", + "writer_busy_during_export_and_resumed_after": true, + "partial_export_not_published": true, + "existing_target_and_symlink_rejected": true +} diff --git a/prototypes/connections/results/2026-09-24-flat-export/export-ext4.json b/prototypes/connections/results/2026-09-24-flat-export/export-ext4.json new file mode 100644 index 0000000..3fc4829 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-flat-export/export-ext4.json @@ -0,0 +1,28 @@ +{ + "objects": 5, + "logical_bytes": 1081346, + "document_digest": "efaa71ebcdaf493723b82bc98532aba3fd5beace476f8b9f61f93706bddf850b", + "paged_reader_sha256": "f29440fcc167d1202b226f0faee450cb299989c8977277e062a330587cec5e39", + "flat_reader_sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec", + "passed": true, + "extra_documents": 0, + "scratch_parent": "/home/eunhhu/work/pure/prototypes/connections/build", + "source_revisions": { + "empty": 1, + "small": 1, + "boundary": 1, + "large": 1, + "megabyte": 66 + }, + "readback_sha256": { + "empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "small": "4b68ab3847feda7d6c62c1fbcbeebfa35eab7351ed5e78f4ddadea5df64b8015", + "boundary": "1bd4db450abc8914c2fac721cace2704ff4c16028e6d07293154dad289835694", + "large": "b10b6985790c800b653eeb8e979f9c1551293ee9c293cbca1c4741df09cc8284", + "megabyte": "3cacf45be36c19ea18974a474bd2c668f24622b9132ab99294b3778ba0124094" + }, + "flat_edit_after_export": "revision 67", + "writer_busy_during_export_and_resumed_after": true, + "partial_export_not_published": true, + "existing_target_and_symlink_rejected": true +} diff --git a/prototypes/connections/results/2026-09-24-flat-export/export-scale-1024.json b/prototypes/connections/results/2026-09-24-flat-export/export-scale-1024.json new file mode 100644 index 0000000..20a2d33 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-flat-export/export-scale-1024.json @@ -0,0 +1,27 @@ +{ + "objects": 1029, + "logical_bytes": 1212418, + "document_digest": "59dabf364a2b8d06e74c275d3679a15b71095544981e90ccbb0932b096c380d1", + "paged_reader_sha256": "f29440fcc167d1202b226f0faee450cb299989c8977277e062a330587cec5e39", + "flat_reader_sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec", + "passed": true, + "extra_documents": 1024, + "source_revisions": { + "empty": 1, + "small": 1, + "boundary": 1, + "large": 1, + "megabyte": 66 + }, + "readback_sha256": { + "empty": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "small": "4b68ab3847feda7d6c62c1fbcbeebfa35eab7351ed5e78f4ddadea5df64b8015", + "boundary": "1bd4db450abc8914c2fac721cace2704ff4c16028e6d07293154dad289835694", + "large": "b10b6985790c800b653eeb8e979f9c1551293ee9c293cbca1c4741df09cc8284", + "megabyte": "3cacf45be36c19ea18974a474bd2c668f24622b9132ab99294b3778ba0124094" + }, + "flat_edit_after_export": "revision 67", + "writer_busy_during_export_and_resumed_after": true, + "partial_export_not_published": true, + "existing_target_and_symlink_rejected": true +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-asan.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-asan.json new file mode 100644 index 0000000..9bd2b7c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-asan.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 11, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115421, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 60, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 14, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 45, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 15, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "bf418bdfd09f865d327c42bd598c3f251138f19e7e6ae0031c4b497affc55002", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "4ae184e15f9f4120333882bd90ea15668bbdaf1cf4fa39f3eb76ba311d129061", + "editor_sha256": "4f2875d8cc8ccd170d4d959f2491c124b1ac11b1debb38ba7f318be1982c8fb6", + "binary_sha256": "ae727ef73c5118acb3a96337ffee7f990f061c597aa22d3d818b1865da06ca6c", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store-paged.c": "da01d9e0e4607ac78d9b057431e1ae6d9bd9aeb9036178b388a1bf1fbe2e7b17", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "584f6671c2daf40ee3cc4233760b0004d5035bd0e0cd10aa0fe6a0b814f66937", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-release.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-release.json new file mode 100644 index 0000000..ca41eb7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/app-release.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11753, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 34, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 15, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "b9642410766ffc6dad9ed3ac38ce9ef550e3a861deb21a4e53dea219687894b6", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "a7e9ec3814b9adf81254bb781a98ac5c2896f69f665e80307f5ec1d824d1089f", + "editor_sha256": "e024d1829ea1a2211c58c33b8ae457794cbda0dbf927cdcb1126cd4de13a16e9", + "binary_sha256": "09b8fb1695ea4ff3a326e75660e77a4e5532f908de7e6fb01a9dec6c840a0d53", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store-paged.c": "da01d9e0e4607ac78d9b057431e1ae6d9bd9aeb9036178b388a1bf1fbe2e7b17", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "584f6671c2daf40ee3cc4233760b0004d5035bd0e0cd10aa0fe6a0b814f66937", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/gtk-flow.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/gtk-flow.json new file mode 100644 index 0000000..233cfc4 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/gtk-flow.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 43632, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/memory.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/memory.json new file mode 100644 index 0000000..e2068b8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/memory.json @@ -0,0 +1,14 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "e024d1829ea1a2211c58c33b8ae457794cbda0dbf927cdcb1126cd4de13a16e9", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-full.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-full.json new file mode 100644 index 0000000..a753696 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-full.json @@ -0,0 +1,21 @@ +{ + "archive_sha256": "40e635bdd6746e13c1073a1d809d8753ddfab94cf973be2771ea9f8ffff0fb3c", + "archive_bytes": 114025, + "selected_hosts": [ + "pure-edit", + "pure-connections", + "pure-document-peer", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "selected storage keeps small values inline and maps large values", + "editing the packaged .pure source changes the executed reply", + "extracted HTTP host updates the same document as the native CLI", + "extracted C/Python TLS peers share a 1 MiB document and recover", + "extracted GTK window and selected external host edit the same document" + ], + "peer_idle_rss_kib": 7840, + "peer_after_large_work_rss_kib": 9504 +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-micro.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-micro.json new file mode 100644 index 0000000..dccd086 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/package-micro.json @@ -0,0 +1,13 @@ +{ + "archive_sha256": "0d3a5da21ebd94cd235259ccfee9173c44afee57e57514e05acc1a43beea8a36", + "archive_bytes": 26045, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "selected storage keeps small values inline and maps large values", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/peer.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/peer.json new file mode 100644 index 0000000..99de24a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/peer.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7856, + "server_after_large_work_rss_kib": 9520 +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/profile.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/profile.json new file mode 100644 index 0000000..c0ae382 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-hybrid-document-storage", + "scope": "Opt-in inline/PUREP001 document store on Linux AArch64/GCC 14. The CLI text ceiling is 42 KiB: 2 KiB above the original selected-page gate because the measured 41064-byte dual-format build fails 40 KiB. This is an explicit research budget, not the default micro-runtime budget. Direct dependencies only; excludes libc, process RAM, filesystem cache and journal, optional TLS/GTK.", + "budget_sha256": "038a323d8492731611dfc56e799e7da751946fe0ea5dc428550ff02ffd781b29", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 41064, + "data": 1000, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "e024d1829ea1a2211c58c33b8ae457794cbda0dbf927cdcb1126cd4de13a16e9" + }, + "pure-connections": { + "sections": { + "text": 47928, + "data": 1088, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "09b8fb1695ea4ff3a326e75660e77a4e5532f908de7e6fb01a9dec6c840a0d53" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-authoring-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-authoring-linux-aarch64.tar.gz new file mode 100644 index 0000000..20dec18 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-authoring-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-micro-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-micro-linux-aarch64.tar.gz new file mode 100644 index 0000000..0234d2b Binary files /dev/null and b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/pure-document-hybrid-micro-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/small-scan.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/small-scan.json new file mode 100644 index 0000000..4dda494 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/small-scan.json @@ -0,0 +1,5 @@ +{ + "small_edit_store_directory_scans": 0, + "unrelated_directory_entries": 256, + "binary_sha256": "e024d1829ea1a2211c58c33b8ae457794cbda0dbf927cdcb1126cd4de13a16e9" +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit-asan.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit-asan.json new file mode 100644 index 0000000..8da4fd1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit-asan.json @@ -0,0 +1 @@ +{"hybrid_storage":"snapshot-patch-range-compaction-crash-conflict-uncertain-inline-transitions","mixed_edits":128} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit.json new file mode 100644 index 0000000..8da4fd1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/unit.json @@ -0,0 +1 @@ +{"hybrid_storage":"snapshot-patch-range-compaction-crash-conflict-uncertain-inline-transitions","mixed_edits":128} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage-v2/write-cost.json b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/write-cost.json new file mode 100644 index 0000000..266c4c8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage-v2/write-cost.json @@ -0,0 +1,49 @@ +{ + "profile": "linux-aarch64-16k-page-document-replacement", + "candidate_mode": "hybrid", + "workloads": [ + "replace last byte of 1 MiB document", + "replace one-byte document" + ], + "unit": "successful user-space write syscall bytes targeting object files", + "excludes": "physical device writes, filesystem journal, metadata and directory sync cost", + "one_mib": { + "flat": { + "object_write_syscall_bytes": 1048600, + "object_write_calls": 258, + "object_files": { + "obj-large": 1048600 + }, + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "hybrid": { + "object_write_syscall_bytes": 16944, + "object_write_calls": 3, + "object_files": { + "obj-large": 560, + "dat-large-0000000000000001": 1064960 + }, + "binary_sha256": "e024d1829ea1a2211c58c33b8ae457794cbda0dbf927cdcb1126cd4de13a16e9" + }, + "flat_to_hybrid_ratio": 61.8862134088763 + }, + "one_byte": { + "flat": { + "object_write_syscall_bytes": 25, + "object_write_calls": 2, + "object_files": { + "obj-tiny": 25 + }, + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "hybrid": { + "object_write_syscall_bytes": 25, + "object_write_calls": 2, + "object_files": { + "obj-tiny": 25 + }, + "binary_sha256": "e024d1829ea1a2211c58c33b8ae457794cbda0dbf927cdcb1126cd4de13a16e9" + }, + "hybrid_to_flat_ratio": 1.0 + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/app-asan.json b/prototypes/connections/results/2026-09-24-hybrid-storage/app-asan.json new file mode 100644 index 0000000..77ff376 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/app-asan.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "03ae899315468bf32215398b094b120c528c36e6337e6da5ebb3435204c2ab82", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "f4761e52b1b65af4b7fd89f3eaa456ca69aa5c0e61e3ade24b0b04c9377af974", + "editor_sha256": "d94ddba81b79796aab53b4131d05332e02e654c35007f48d9073ce410c5ecee0", + "binary_sha256": "db7fd98350bf3a33435921035871f33e18dad0a3b28360671544a3d34a6a93ce", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store-paged.c": "02c5992465d117e576199c7348daa2b628e6c6efd41d906faa3b50713ece9f53", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "584f6671c2daf40ee3cc4233760b0004d5035bd0e0cd10aa0fe6a0b814f66937", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/app-release.json b/prototypes/connections/results/2026-09-24-hybrid-storage/app-release.json new file mode 100644 index 0000000..452cadb --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/app-release.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 14227, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 16, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "dd505a9c4ecf73205f33edb491ed990234cd940c6164cd9fe56c1606dc4a280f", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "9b7366c1d005a21c393425e28c18bd161fcbb86af3961180780d67f5d62ba3d8", + "editor_sha256": "5ea320034f2c36f378bed30bb12705f35723dd9f7f334c976aec7d6f5927ac57", + "binary_sha256": "8bc87de02efca32d1281976a8e8bea2e5a7242eb5ec07fc204b7762c2081021c", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store-paged.c": "02c5992465d117e576199c7348daa2b628e6c6efd41d906faa3b50713ece9f53", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "584f6671c2daf40ee3cc4233760b0004d5035bd0e0cd10aa0fe6a0b814f66937", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/gtk-flow.json b/prototypes/connections/results/2026-09-24-hybrid-storage/gtk-flow.json new file mode 100644 index 0000000..4bff12d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/gtk-flow.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 43760, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/memory.json b/prototypes/connections/results/2026-09-24-hybrid-storage/memory.json new file mode 100644 index 0000000..64304e0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/memory.json @@ -0,0 +1,14 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "5ea320034f2c36f378bed30bb12705f35723dd9f7f334c976aec7d6f5927ac57", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/package-full.json b/prototypes/connections/results/2026-09-24-hybrid-storage/package-full.json new file mode 100644 index 0000000..0675a20 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/package-full.json @@ -0,0 +1,21 @@ +{ + "archive_sha256": "0aa6141240a44039ec5c3e542c9c2857678620c77c81203dd984b4121b18adce", + "archive_bytes": 114009, + "selected_hosts": [ + "pure-edit", + "pure-connections", + "pure-document-peer", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "selected storage keeps small values inline and maps large values", + "editing the packaged .pure source changes the executed reply", + "extracted HTTP host updates the same document as the native CLI", + "extracted C/Python TLS peers share a 1 MiB document and recover", + "extracted GTK window and selected external host edit the same document" + ], + "peer_idle_rss_kib": 7840, + "peer_after_large_work_rss_kib": 9536 +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/package-micro.json b/prototypes/connections/results/2026-09-24-hybrid-storage/package-micro.json new file mode 100644 index 0000000..62cfd78 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/package-micro.json @@ -0,0 +1,13 @@ +{ + "archive_sha256": "c67a92e534a43aed24254995078bb72992015766da9c3e57ec7c993fa50f3654", + "archive_bytes": 26040, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "selected storage keeps small values inline and maps large values", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/peer.json b/prototypes/connections/results/2026-09-24-hybrid-storage/peer.json new file mode 100644 index 0000000..fa238e3 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/peer.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7840, + "server_after_large_work_rss_kib": 9536 +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/profile.json b/prototypes/connections/results/2026-09-24-hybrid-storage/profile.json new file mode 100644 index 0000000..96ca222 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-hybrid-document-storage", + "scope": "Opt-in inline/PUREP001 document store on Linux AArch64/GCC 14. The CLI text ceiling is 42 KiB: 2 KiB above the original selected-page gate because the measured 41064-byte dual-format build fails 40 KiB. This is an explicit research budget, not the default micro-runtime budget. Direct dependencies only; excludes libc, process RAM, filesystem cache and journal, optional TLS/GTK.", + "budget_sha256": "038a323d8492731611dfc56e799e7da751946fe0ea5dc428550ff02ffd781b29", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 41064, + "data": 1000, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "5ea320034f2c36f378bed30bb12705f35723dd9f7f334c976aec7d6f5927ac57" + }, + "pure-connections": { + "sections": { + "text": 47928, + "data": 1088, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8bc87de02efca32d1281976a8e8bea2e5a7242eb5ec07fc204b7762c2081021c" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-authoring-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-authoring-linux-aarch64.tar.gz new file mode 100644 index 0000000..5280fd4 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-authoring-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-micro-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-micro-linux-aarch64.tar.gz new file mode 100644 index 0000000..c2fb13f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-hybrid-storage/pure-document-hybrid-micro-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/unit-asan.json b/prototypes/connections/results/2026-09-24-hybrid-storage/unit-asan.json new file mode 100644 index 0000000..8da4fd1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/unit-asan.json @@ -0,0 +1 @@ +{"hybrid_storage":"snapshot-patch-range-compaction-crash-conflict-uncertain-inline-transitions","mixed_edits":128} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/unit.json b/prototypes/connections/results/2026-09-24-hybrid-storage/unit.json new file mode 100644 index 0000000..8da4fd1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/unit.json @@ -0,0 +1 @@ +{"hybrid_storage":"snapshot-patch-range-compaction-crash-conflict-uncertain-inline-transitions","mixed_edits":128} diff --git a/prototypes/connections/results/2026-09-24-hybrid-storage/write-cost.json b/prototypes/connections/results/2026-09-24-hybrid-storage/write-cost.json new file mode 100644 index 0000000..a3dfa76 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-hybrid-storage/write-cost.json @@ -0,0 +1,49 @@ +{ + "profile": "linux-aarch64-16k-page-document-replacement", + "candidate_mode": "hybrid", + "workloads": [ + "replace last byte of 1 MiB document", + "replace one-byte document" + ], + "unit": "successful user-space write syscall bytes targeting object files", + "excludes": "physical device writes, filesystem journal, metadata and directory sync cost", + "one_mib": { + "flat": { + "object_write_syscall_bytes": 1048600, + "object_write_calls": 258, + "object_files": { + "obj-large": 1048600 + }, + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "hybrid": { + "object_write_syscall_bytes": 16944, + "object_write_calls": 3, + "object_files": { + "obj-large": 560, + "dat-large-0000000000000001": 1064960 + }, + "binary_sha256": "5ea320034f2c36f378bed30bb12705f35723dd9f7f334c976aec7d6f5927ac57" + }, + "flat_to_hybrid_ratio": 61.8862134088763 + }, + "one_byte": { + "flat": { + "object_write_syscall_bytes": 25, + "object_write_calls": 2, + "object_files": { + "obj-tiny": 25 + }, + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "hybrid": { + "object_write_syscall_bytes": 25, + "object_write_calls": 2, + "object_files": { + "obj-tiny": 25 + }, + "binary_sha256": "5ea320034f2c36f378bed30bb12705f35723dd9f7f334c976aec7d6f5927ac57" + }, + "hybrid_to_flat_ratio": 1.0 + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/archived-comparison.json b/prototypes/connections/results/2026-09-24-idle-wake/archived-comparison.json new file mode 100644 index 0000000..f90f1a9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/archived-comparison.json @@ -0,0 +1,21 @@ +{ + "profile": "idle-http-host-poll-syscalls", + "observation_seconds": 1.25, + "before": { + "poll_calls": 25, + "timeout_values_ms": [ + 50 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=PO", + "binary_sha256": "7dd0010fa6d6024afa2fe35d57a4c73f33ac632e732616ac8f8853d7b22d79b3" + }, + "after": { + "poll_calls": 1, + "timeout_values_ms": [ + -1 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, NULL, NULL, 0) = ? ERESTARTNOHAND (To be restarted if no handler)\n--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=927636, si_uid=1000} ---\n", + "binary_sha256": "3e8162f6851ba407061d1109a87b9b2b17c87a5c5eadbc75ea7cc0fb27a84501" + }, + "deadline_driven_idle_wait": "pass" +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/fixed-profile.json b/prototypes/connections/results/2026-09-24-idle-wake/fixed-profile.json new file mode 100644 index 0000000..9a57d8c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8" + }, + "pure-connections-fixed": { + "sections": { + "text": 25292, + "data": 1000, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "3e8162f6851ba407061d1109a87b9b2b17c87a5c5eadbc75ea7cc0fb27a84501" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/fixed-server-regression.json b/prototypes/connections/results/2026-09-24-idle-wake/fixed-server-regression.json new file mode 100644 index 0000000..5400394 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/fixed-server-regression.json @@ -0,0 +1,68 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_idle_rss_kib": 1504, + "source_idle_rss_kib": 1536, + "fixed_after_large_rss_kib": 1632, + "source_after_large_rss_kib": 1664, + "fixed_peak_rss_kib": 2128, + "source_peak_rss_kib": 1664, + "fixed_peak_rss_limit_kib": 4096, + "fixed_sha256": "3e8162f6851ba407061d1109a87b9b2b17c87a5c5eadbc75ea7cc0fb27a84501", + "source_sha256": "186fd0ab0f7cd32cc46a341cc4e6ec784126193810473c6ac28a8c95a0d44bc3" +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/idle.json b/prototypes/connections/results/2026-09-24-idle-wake/idle.json new file mode 100644 index 0000000..dee7a74 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/idle.json @@ -0,0 +1,21 @@ +{ + "profile": "idle-http-host-poll-syscalls", + "observation_seconds": 1.25, + "before": { + "poll_calls": 25, + "timeout_values_ms": [ + 50 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=PO", + "binary_sha256": "02acf468f18610332d3fe4b9c093b2bbff08496000dbae2b61151d7f3a7a46fa" + }, + "after": { + "poll_calls": 1, + "timeout_values_ms": [ + -1 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, NULL, NULL, 0) = ? ERESTARTNOHAND (To be restarted if no handler)\n--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=927237, si_uid=1000} ---\n", + "binary_sha256": "3e8162f6851ba407061d1109a87b9b2b17c87a5c5eadbc75ea7cc0fb27a84501" + }, + "deadline_driven_idle_wait": "pass" +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/package.json b/prototypes/connections/results/2026-09-24-idle-wake/package.json new file mode 100644 index 0000000..61d484b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "669d28751faa4da34dd63cb52e83fa2cfb11146a9844b0f6378f9f63500a0b71", + "archive_bytes": 31773, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/pure-document-fixed-http-idle-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-idle-wake/pure-document-fixed-http-idle-linux-aarch64.tar.gz new file mode 100644 index 0000000..24d9b3d Binary files /dev/null and b/prototypes/connections/results/2026-09-24-idle-wake/pure-document-fixed-http-idle-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-idle-wake/reproducible-comparison.json b/prototypes/connections/results/2026-09-24-idle-wake/reproducible-comparison.json new file mode 100644 index 0000000..4a17350 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/reproducible-comparison.json @@ -0,0 +1,21 @@ +{ + "profile": "idle-http-host-poll-syscalls", + "observation_seconds": 1.25, + "before": { + "poll_calls": 25, + "timeout_values_ms": [ + 50 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=PO", + "binary_sha256": "7dd0010fa6d6024afa2fe35d57a4c73f33ac632e732616ac8f8853d7b22d79b3" + }, + "after": { + "poll_calls": 1, + "timeout_values_ms": [ + -1 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, NULL, NULL, 0) = ? ERESTARTNOHAND (To be restarted if no handler)\n--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=928742, si_uid=1000} ---\n", + "binary_sha256": "3e8162f6851ba407061d1109a87b9b2b17c87a5c5eadbc75ea7cc0fb27a84501" + }, + "deadline_driven_idle_wait": "pass" +} diff --git a/prototypes/connections/results/2026-09-24-idle-wake/source-profile.json b/prototypes/connections/results/2026-09-24-idle-wake/source-profile.json new file mode 100644 index 0000000..05a247c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-wake/source-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35807, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7" + }, + "pure-connections": { + "sections": { + "text": 43073, + "data": 1040, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "186fd0ab0f7cd32cc46a341cc4e6ec784126193810473c6ac28a8c95a0d44bc3" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/async-core.json b/prototypes/connections/results/2026-09-24-idle-work-binding/async-core.json new file mode 100644 index 0000000..4843a89 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/async-core.json @@ -0,0 +1,55 @@ +{ + "passed": true, + "failures": [], + "budget": { + "profile": "selected-owner-thread-async-effects", + "max_text_bytes": 8192, + "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, + "effect_bytes": 128 + }, + "archive_members": [ + "runtime-async.o", + "evaluate.o" + ], + "external_symbols_allowed": [ + "memcpy", + "memmove", + "memset", + "strlen" + ], + "scope": "Selected owner-thread completion extension. Host I/O and worker stacks are excluded. The default execution core and fixed document packages do not link this profile." + }, + "layout": { + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8, + "effect_bytes": 128 + }, + "sections": { + "text": 7456, + "data": 0, + "bss": 0 + }, + "members": [ + "runtime-async.o", + "evaluate.o" + ], + "external_symbols": [ + "memcpy", + "strlen" + ], + "size_output": " text\t data\t bss\t dec\t hex\tfilename\n 6536\t 0\t 0\t 6536\t 1988\truntime-async.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution-async.a)\n 920\t 0\t 0\t 920\t 398\tevaluate.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution-async.a)\n", + "archive_sha256": "897fb2f6eff5dd07efcf0789107bfcf87423fc90aae084df169601451f23d07f", + "layout_binary_sha256": "71866a57a3a2af37a8e3d4f2f97669c482b11b3a408a1d918e5de068dab9e34a", + "budget_sha256": "496e34596e24418efb46e460126b7d67492137207be988525abda1397d70c539", + "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/connections/results/2026-09-24-idle-work-binding/async-gated.json b/prototypes/connections/results/2026-09-24-idle-work-binding/async-gated.json new file mode 100644 index 0000000..92f9838 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/async-gated.json @@ -0,0 +1,53 @@ +{ + "async_http": "snapshot-read-write-overlap-deadline-recovery-draining-shutdown", + "before_publication": { + "status": 200, + "etag": "\"1\"", + "body": "old" + }, + "after_publication": { + "status": 200, + "etag": "\"2\"", + "body": "oldafter" + }, + "shutdown_restore": { + "revision": "2", + "body": "oldafter" + }, + "deadline_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 1, + "releases": 3, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 16, + "server_structure_bytes": 43320 + }, + "shutdown_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 13, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 13, + "borrowed_output_bytes": 5, + "server_structure_bytes": 43320 + }, + "test_source_sha256": "f38c9d3d784676ce815c276fbffc10e00f767aaa803793d8db311c19ef3b9085", + "gated_test_binary_sha256": "cb4e7209c53261b63647823aaa8ee408a860d1ccde6c85bc404d77a336128eb2" +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/async-server-profile.json b/prototypes/connections/results/2026-09-24-idle-work-binding/async-server-profile.json new file mode 100644 index 0000000..a3a89b8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/async-server-profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-read-write-document-http", + "scope": "Optional one-read/one-write-worker HTTP host on the recorded Linux AArch64/GCC 14 toolchain. Section limits exclude the two 128 KiB worker stacks, libc, heap, mapped files and kernel buffers. Default CLI and HTTP do not link this option. Inspect process RSS and VM separately.", + "budget_sha256": "acd82386572fc2f874ca2fee5f4eed68ee145cf990d7315a9f1aff06daf51995", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-connections-async": { + "sections": { + "text": 48561, + "data": 1180, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "9485151b8902d66052bb689e821b502810b66561e0a251d3ce091c4919b7c67e" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-check.json b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-check.json new file mode 100644 index 0000000..1f60a56 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "927e9b0f7bc672e661da0d82abe60aaf3743f6753fbb00e6b39035a3239bae22", + "archive_bytes": 12987, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-check.json b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-check.json new file mode 100644 index 0000000..0f372bf --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "177bb19c9790712ccb8f75fd65eb59b02c68b526b5155d3b1e83a795c845f031", + "archive_bytes": 28719, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui-check.json b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui-check.json new file mode 100644 index 0000000..5a6506c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "081e5fe6205760e343ca237b74ff969a10d169a5f51b3bcd07a50ac8c5e0ba23", + "archive_bytes": 47419, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui.tar.gz b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui.tar.gz new file mode 100644 index 0000000..de378e4 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http.tar.gz b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http.tar.gz new file mode 100644 index 0000000..904f570 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-http.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui-check.json b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui-check.json new file mode 100644 index 0000000..e1b39e7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "565d32fcccab9dfa10c8af7e21514fb1f4187b0bebac52053db02a6b75bf741f", + "archive_bytes": 31726, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui.tar.gz b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui.tar.gz new file mode 100644 index 0000000..33a17ee Binary files /dev/null and b/prototypes/connections/results/2026-09-24-idle-work-binding/cli-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/cli.tar.gz b/prototypes/connections/results/2026-09-24-idle-work-binding/cli.tar.gz new file mode 100644 index 0000000..739dda5 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-idle-work-binding/cli.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/core.json b/prototypes/connections/results/2026-09-24-idle-work-binding/core.json new file mode 100644 index 0000000..735a4df --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/core.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": 6076, + "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 5156\t 0\t 0\t 5156\t 1424\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": "a02135602c3b18129cc9b80cb808994426d0c7869754eac1370c304294ad0be4", + "layout_binary_sha256": "db112a9cfd8deed80b9bfbff7628958fdb290cadab1efc7223d8d15ef4c4c389", + "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/connections/results/2026-09-24-idle-work-binding/default-profile.json b/prototypes/connections/results/2026-09-24-idle-work-binding/default-profile.json new file mode 100644 index 0000000..a997add --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/default-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35967, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "dfe2b9d03c87eed8cde0d598b182dfc8aa4bba85eca1b64849c77e9c4ef302d3" + }, + "pure-connections": { + "sections": { + "text": 43938, + "data": 1068, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "1b7555764d059bca2be05e406d42305dfc68e3fd87813bcfcfaa390945467a8d" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/document-peer-profile.json b/prototypes/connections/results/2026-09-24-idle-work-binding/document-peer-profile.json new file mode 100644 index 0000000..18dbba5 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/document-peer-profile.json @@ -0,0 +1,23 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-document-peer", + "scope": "Review threshold for the explicitly selected authenticated document host on Linux AArch64/GCC 14. Includes linked application code but excludes the shared OpenSSL/libc implementations, process heap, stack, mappings and kernel buffers. Check measured whole-process RSS separately.", + "budget_sha256": "defe8efb4d544cdbf615de4d43d4ceba78fc7f1d958f245731202625052eb2ff", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-document-peer": { + "sections": { + "text": 47038, + "data": 1384, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libcrypto.so.3", + "libssl.so.3" + ], + "sha256": "3ec358021988217c212f5527f8947c301f39d6c2022f82c51deff515ce16acbf" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/document-peer.json b/prototypes/connections/results/2026-09-24-idle-work-binding/document-peer.json new file mode 100644 index 0000000..05c62cc --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/document-peer.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7968, + "server_after_large_work_rss_kib": 9712 +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/fixed-memory.json b/prototypes/connections/results/2026-09-24-idle-work-binding/fixed-memory.json new file mode 100644 index 0000000..641bc64 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/fixed-memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, the embedded prepared program, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1088, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1088 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "3d2bf30330330ca377d28db0a7d8f4598365c3cfba9c2d43dc5843246fff19b5", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/fixed-profile.json b/prototypes/connections/results/2026-09-24-idle-work-binding/fixed-profile.json new file mode 100644 index 0000000..6097cdf --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19404, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "3d2bf30330330ca377d28db0a7d8f4598365c3cfba9c2d43dc5843246fff19b5" + }, + "pure-connections-fixed": { + "sections": { + "text": 26549, + "data": 1028, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "2869469f4c40674c6904729141005c7705bbc5130019d1ce091ffb135bffe60a" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/full-check.json b/prototypes/connections/results/2026-09-24-idle-work-binding/full-check.json new file mode 100644 index 0000000..0e4d482 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/full-check.json @@ -0,0 +1,636 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 14227, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 113, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true, + "idle_slot_binding": true + }, + "execution_core_test_sha256": "80aaa8831c44101fc03364d393dd99ac1f3ca3fa861e2561447adad6d965ac0a", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "43bff520b326cfe3f17fa0e0b48efc27e393f6cb7bfff3051fe0115b23e4e5fb", + "editor_sha256": "dfe2b9d03c87eed8cde0d598b182dfc8aa4bba85eca1b64849c77e9c4ef302d3", + "binary_sha256": "1b7555764d059bca2be05e406d42305dfc68e3fd87813bcfcfaa390945467a8d", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "ead1331794f8e4fb851e21eb7f7d3e7c408316d7481c51996e6ee62a81665a0c", + "prototypes/connections/runtime.h": "beff8a8a10f21c017987d69682bb279701d5627227aca60029a711b9e64cca85", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "8c275bf564a674f0f8232026d0d7612df48f10054af75a43af5ed0bce7653e24", + "prototypes/connections/server.c": "e8632667de6feaf4d495f87abb159775650e3e4eaf6713b9dfdc5f82807c2bf9", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "f39a7c411afa80812aec6e7a7250cdc833e97cffef8d0cce25d960fe435784f6", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "f9a0c9fa7d0f40d6d72bc72a429dd36e136e27207aa6b0c01df9505d82189b6d", + "prototypes/connections/core-layout.c": "e3123bdf74edb99556b05d7cd3a36b6119ec30829541b0ddba2e23e5900b089d", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/package-reproducibility.json b/prototypes/connections/results/2026-09-24-idle-work-binding/package-reproducibility.json new file mode 100644 index 0000000..d9bb9fe --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/package-reproducibility.json @@ -0,0 +1,31 @@ +{ + "profile": "native-package-repeat-build", + "same_archive_bytes": "pass", + "variants": { + "source-cli": { + "bytes": 22595, + "sha256": "e770e93d15524070b254a46796667e6b0077bc0fa95fbf6ecca051742ec754ca", + "runs": 2 + }, + "cli": { + "bytes": 12987, + "sha256": "927e9b0f7bc672e661da0d82abe60aaf3743f6753fbb00e6b39035a3239bae22", + "runs": 2 + }, + "cli-http": { + "bytes": 28719, + "sha256": "177bb19c9790712ccb8f75fd65eb59b02c68b526b5155d3b1e83a795c845f031", + "runs": 2 + }, + "cli-tui": { + "bytes": 31726, + "sha256": "565d32fcccab9dfa10c8af7e21514fb1f4187b0bebac52053db02a6b75bf741f", + "runs": 2 + }, + "cli-http-tui": { + "bytes": 47419, + "sha256": "081e5fe6205760e343ca237b74ff969a10d169a5f51b3bcd07a50ac8c5e0ba23", + "runs": 2 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/precompact-budget-failure.txt b/prototypes/connections/results/2026-09-24-idle-work-binding/precompact-budget-failure.txt new file mode 100644 index 0000000..28b393a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/precompact-budget-failure.txt @@ -0,0 +1,9 @@ +Before fixed packages switched from strip --strip-debug to strip --strip-unneeded: + +python3 package_native.py --fixed --with-tui --output results/2026-09-24-idle-work-binding/cli-tui.tar.gz + +package_native.py: error: cli_tui archive grew to 35847 bytes (reviewed maximum 35840); no package was published + +This is the captured result on Linux AArch64 with the updated runtime and the +earlier packaging mode. The final package archive and relocation check are +cli-tui.tar.gz and cli-tui-check.json in this directory. diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/release.log b/prototypes/connections/results/2026-09-24-idle-work-binding/release.log new file mode 100644 index 0000000..5c3a893 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/release.log @@ -0,0 +1,800 @@ +python3 check_core.py --archive build/libpure-execution.a --layout build/core-layout --output "results/2026-09-24-idle-work-binding/core.json" +{ + "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": 6076, + "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 5156\t 0\t 0\t 5156\t 1424\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": "a02135602c3b18129cc9b80cb808994426d0c7869754eac1370c304294ad0be4", + "layout_binary_sha256": "db112a9cfd8deed80b9bfbff7628958fdb290cadab1efc7223d8d15ef4c4c389", + "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" +} +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror edit.c store.c store-port.c program.c runtime.c evaluate.c -o build/pure-edit +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror server.c store.c store-port.c flow.c program.c runtime.c evaluate.c -o build/pure-connections +python3 check_profile.py --build build --output "results/2026-09-24-idle-work-binding/default-profile.json" +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35967, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "dfe2b9d03c87eed8cde0d598b182dfc8aa4bba85eca1b64849c77e9c4ef302d3" + }, + "pure-connections": { + "sections": { + "text": 43938, + "data": 1068, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "1b7555764d059bca2be05e406d42305dfc68e3fd87813bcfcfaa390945467a8d" + } + } +} +build/pure-compile examples/editor.pure build/embedded-plan.h +python3 check_profile.py --build build --budget fixed-budget.json --output "results/2026-09-24-idle-work-binding/fixed-profile.json" +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19404, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "3d2bf30330330ca377d28db0a7d8f4598365c3cfba9c2d43dc5843246fff19b5" + }, + "pure-connections-fixed": { + "sections": { + "text": 26549, + "data": 1028, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "2869469f4c40674c6904729141005c7705bbc5130019d1ce091ffb135bffe60a" + } + } +} +python3 check_micro_memory.py --build build --fixed --output "results/2026-09-24-idle-work-binding/fixed-memory.json" +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "fixed-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, the embedded prepared program, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1088, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1088 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "3d2bf30330330ca377d28db0a7d8f4598365c3cfba9c2d43dc5843246fff19b5", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror test-runtime.c program.c runtime.c evaluate.c store.c store-port.c -o build/test-runtime +cc -O2 -g -std=c17 -Wall -Wextra -Wpedantic -Werror -DPURE_STORE_TEST test-splice.c program.c runtime.c evaluate.c store.c store-port.c -o build/test-splice +build/test-flow +{"transfer":"partial-consumption-and-single-release","storage":"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty"} +build/test-program +{"program":"prepare-views-preserve-guards-types-effects-and-atomic-install","boundary_semantic_comparisons":3584} +build/test-fields +{"fields":"named-layouts-unaligned-u32le-tail-dynamic-ranges-aliases-failures-limits","data_boundary_cases":48} +python3 check.py --server build/pure-connections --runtime-test build/test-runtime --splice-test build/test-splice --editor build/pure-edit --store-source store.c --output "results/2026-09-24-idle-work-binding/full-check.json" +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 14227, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 113, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true, + "idle_slot_binding": true + }, + "execution_core_test_sha256": "80aaa8831c44101fc03364d393dd99ac1f3ca3fa861e2561447adad6d965ac0a", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "43bff520b326cfe3f17fa0e0b48efc27e393f6cb7bfff3051fe0115b23e4e5fb", + "editor_sha256": "dfe2b9d03c87eed8cde0d598b182dfc8aa4bba85eca1b64849c77e9c4ef302d3", + "binary_sha256": "1b7555764d059bca2be05e406d42305dfc68e3fd87813bcfcfaa390945467a8d", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "ead1331794f8e4fb851e21eb7f7d3e7c408316d7481c51996e6ee62a81665a0c", + "prototypes/connections/runtime.h": "beff8a8a10f21c017987d69682bb279701d5627227aca60029a711b9e64cca85", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "8c275bf564a674f0f8232026d0d7612df48f10054af75a43af5ed0bce7653e24", + "prototypes/connections/server.c": "e8632667de6feaf4d495f87abb159775650e3e4eaf6713b9dfdc5f82807c2bf9", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "f39a7c411afa80812aec6e7a7250cdc833e97cffef8d0cce25d960fe435784f6", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "f9a0c9fa7d0f40d6d72bc72a429dd36e136e27207aa6b0c01df9505d82189b6d", + "prototypes/connections/core-layout.c": "e3123bdf74edb99556b05d7cd3a36b6119ec30829541b0ddba2e23e5900b089d", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} +python3 check_micro_memory.py --build build --output "results/2026-09-24-idle-work-binding/source-memory.json" +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "source-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "dfe2b9d03c87eed8cde0d598b182dfc8aa4bba85eca1b64849c77e9c4ef302d3", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/runtime-sanitized.json b/prototypes/connections/results/2026-09-24-idle-work-binding/runtime-sanitized.json new file mode 100644 index 0000000..0c1b5b0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/runtime-sanitized.json @@ -0,0 +1 @@ +{"runtime_tests":"passed","domain_bytes":200,"work_slot_bytes":224,"ticket_bytes":24,"prepared_program_bytes":680,"forwarding_chain":1024,"native_storage_pipeline":true,"idle_slot_binding":true} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/service-scale.json b/prototypes/connections/results/2026-09-24-idle-work-binding/service-scale.json new file mode 100644 index 0000000..b0fe27b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/service-scale.json @@ -0,0 +1,100 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "2869469f4c40674c6904729141005c7705bbc5130019d1ce091ffb135bffe60a", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1504, + "peak_rss_kib": 1680, + "idle_vm_kib": 2448, + "peak_vm_kib": 2528, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49184, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 390209, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "1b7555764d059bca2be05e406d42305dfc68e3fd87813bcfcfaa390945467a8d", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1552, + "peak_rss_kib": 1728, + "idle_vm_kib": 2464, + "peak_vm_kib": 2528, + "idle_threads": 1, + "peak_threads": 1, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49184, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 442020, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + }, + "source_async": { + "binary_sha256": "9485151b8902d66052bb689e821b502810b66561e0a251d3ce091c4919b7c67e", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1600, + "peak_rss_kib": 1776, + "idle_vm_kib": 2864, + "peak_vm_kib": 2944, + "idle_threads": 3, + "peak_threads": 3, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 40993, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 270839, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 43320 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/source-cli-check.json b/prototypes/connections/results/2026-09-24-idle-work-binding/source-cli-check.json new file mode 100644 index 0000000..729710b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/source-cli-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "e770e93d15524070b254a46796667e6b0077bc0fa95fbf6ecca051742ec754ca", + "archive_bytes": 22595, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/source-cli.tar.gz b/prototypes/connections/results/2026-09-24-idle-work-binding/source-cli.tar.gz new file mode 100644 index 0000000..4981e60 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-idle-work-binding/source-cli.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/source-memory.json b/prototypes/connections/results/2026-09-24-idle-work-binding/source-memory.json new file mode 100644 index 0000000..aa663a6 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/source-memory.json @@ -0,0 +1,15 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "variant": "source-program", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "dfe2b9d03c87eed8cde0d598b182dfc8aa4bba85eca1b64849c77e9c4ef302d3", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/tui-profile.json b/prototypes/connections/results/2026-09-24-idle-work-binding/tui-profile.json new file mode 100644 index 0000000..ead0b8f --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/tui-profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app, including the bounded document browser. The raised text ceiling reviews its measured 2,541-byte addition; default CLI and core ceilings are unchanged. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS, including 1,024-key browsing, is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "4d3097c9ec3c0139cbdd82161a74d610c15d31c6ee5625101410627664f925e7", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 31463, + "data": 1036, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "297acfbeee36de36f4651e3ae5438c8a91604d97be9bc753fe2af380887b8693" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-idle-work-binding/tui.json b/prototypes/connections/results/2026-09-24-idle-work-binding/tui.json new file mode 100644 index 0000000..d482848 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-idle-work-binding/tui.json @@ -0,0 +1,22 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, resized and last key opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1808, + "one_mib_peak_rss_kib": 2832, + "one_thousand_key_list_peak_rss_kib": 1888, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "297acfbeee36de36f4651e3ae5438c8a91604d97be9bc753fe2af380887b8693" +} diff --git a/prototypes/connections/results/2026-09-24-in-process-routing/mirror-ext4.json b/prototypes/connections/results/2026-09-24-in-process-routing/mirror-ext4.json new file mode 100644 index 0000000..0b1454e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-in-process-routing/mirror-ext4.json @@ -0,0 +1,56 @@ +{ + "passed": true, + "source_body_sha256": "06cb5e32505f66804f6e8c79e3230105ca4769444e1389ee36f6f11fd61f2d3a", + "mirror_sha256": "3c22ef868198f805f4cd1fe7c9c55b69f32c599f5076444f9af4f1297cb874e2", + "editor_sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec", + "source_one_mib_to_two_domains": { + "source_revision": "1", + "bytes": 1048576, + "targets": [ + { + "index": 0, + "status": "ok", + "revision": "1" + }, + { + "index": 1, + "status": "ok", + "revision": "1" + } + ] + }, + "independent_partial_conflict": { + "source_revision": "2", + "bytes": 1048576, + "targets": [ + { + "index": 0, + "status": "ok", + "revision": "2" + }, + { + "index": 1, + "status": "conflict", + "revision": "1" + } + ] + }, + "conflict_recovered_revision": "2", + "empty_body_forwarded": true, + "target_budget_checked_before_document_write": true, + "all_admissions_before_document_writes": true, + "source_failure_before_target_creation": true, + "one_mib_two_target_peak_rss": { + "exit_code": 0, + "max_rss_kib": 2144, + "raw_max_rss": 2144, + "raw_unit": "KiB" + }, + "one_mib_eight_targets_verified": true, + "one_mib_eight_target_peak_rss": { + "exit_code": 0, + "max_rss_kib": 2144, + "raw_max_rss": 2144, + "raw_unit": "KiB" + } +} diff --git a/prototypes/connections/results/2026-09-24-in-process-routing/mirror-sanitized.json b/prototypes/connections/results/2026-09-24-in-process-routing/mirror-sanitized.json new file mode 100644 index 0000000..12f3550 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-in-process-routing/mirror-sanitized.json @@ -0,0 +1,46 @@ +{ + "passed": true, + "source_body_sha256": "06cb5e32505f66804f6e8c79e3230105ca4769444e1389ee36f6f11fd61f2d3a", + "mirror_sha256": "0c25b281c3b746f74a451bb3eefb2345c98f3ba01a08d5751347246f5bc48ee1", + "editor_sha256": "6d0b6b2b06ed25e0fc0db229091d19fe56de01bae5f5a25c72ee228589412541", + "source_one_mib_to_two_domains": { + "source_revision": "1", + "bytes": 1048576, + "targets": [ + { + "index": 0, + "status": "ok", + "revision": "1" + }, + { + "index": 1, + "status": "ok", + "revision": "1" + } + ] + }, + "independent_partial_conflict": { + "source_revision": "2", + "bytes": 1048576, + "targets": [ + { + "index": 0, + "status": "ok", + "revision": "2" + }, + { + "index": 1, + "status": "conflict", + "revision": "1" + } + ] + }, + "conflict_recovered_revision": "2", + "empty_body_forwarded": true, + "target_budget_checked_before_document_write": true, + "all_admissions_before_document_writes": true, + "source_failure_before_target_creation": true, + "one_mib_two_target_peak_rss": null, + "one_mib_eight_targets_verified": true, + "one_mib_eight_target_peak_rss": null +} diff --git a/prototypes/connections/results/2026-09-24-in-process-routing/mirror-tmp.json b/prototypes/connections/results/2026-09-24-in-process-routing/mirror-tmp.json new file mode 100644 index 0000000..01f80dd --- /dev/null +++ b/prototypes/connections/results/2026-09-24-in-process-routing/mirror-tmp.json @@ -0,0 +1,56 @@ +{ + "passed": true, + "source_body_sha256": "06cb5e32505f66804f6e8c79e3230105ca4769444e1389ee36f6f11fd61f2d3a", + "mirror_sha256": "3c22ef868198f805f4cd1fe7c9c55b69f32c599f5076444f9af4f1297cb874e2", + "editor_sha256": "04974e2d3c4d7f5fb22aff67e7399aa2ebcbd85eb24da0c0e41f3ef047d5ddec", + "source_one_mib_to_two_domains": { + "source_revision": "1", + "bytes": 1048576, + "targets": [ + { + "index": 0, + "status": "ok", + "revision": "1" + }, + { + "index": 1, + "status": "ok", + "revision": "1" + } + ] + }, + "independent_partial_conflict": { + "source_revision": "2", + "bytes": 1048576, + "targets": [ + { + "index": 0, + "status": "ok", + "revision": "2" + }, + { + "index": 1, + "status": "conflict", + "revision": "1" + } + ] + }, + "conflict_recovered_revision": "2", + "empty_body_forwarded": true, + "target_budget_checked_before_document_write": true, + "all_admissions_before_document_writes": true, + "source_failure_before_target_creation": true, + "one_mib_two_target_peak_rss": { + "exit_code": 0, + "max_rss_kib": 2128, + "raw_max_rss": 2128, + "raw_unit": "KiB" + }, + "one_mib_eight_targets_verified": true, + "one_mib_eight_target_peak_rss": { + "exit_code": 0, + "max_rss_kib": 2128, + "raw_max_rss": 2128, + "raw_unit": "KiB" + } +} diff --git a/prototypes/connections/results/2026-09-24-micro-default/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-micro-default/pure-source-preview.tar.gz new file mode 100644 index 0000000..20674be Binary files /dev/null and b/prototypes/connections/results/2026-09-24-micro-default/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-micro-default/source-package-check.json b/prototypes/connections/results/2026-09-24-micro-default/source-package-check.json new file mode 100644 index 0000000..bfd1940 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-micro-default/source-package-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "26bdd3f8768bc5c66c282f989026ac4d9de64398ed3c31e6dc8abbc3d84491d0", + "archive_bytes": 338097, + "source_files": 173, + "checks": [ + "every extracted source file matches the manifest", + "clean default build contains only the core, parser-free CLI and its build-time compiler", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli-check.json b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli-check.json new file mode 100644 index 0000000..da74f01 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "3e5b064bbf81b13be24494a5d5a9bd55fa5ea057d2d5614965f3e5556babf9ab", + "archive_bytes": 14692, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli.tar.gz b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli.tar.gz new file mode 100644 index 0000000..7467329 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http-check.json b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http-check.json new file mode 100644 index 0000000..1d8281a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "d7f35c8add62551bfaf9819e4edd64b8b3f25dc084b2c519d172d4344f66b160", + "archive_bytes": 32548, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http.tar.gz b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http.tar.gz new file mode 100644 index 0000000..4ce4a1a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui-check.json b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui-check.json new file mode 100644 index 0000000..e247b61 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "6dc051ebc74eaf460c97e7259d0d408b2424ae4cfd19078a4227e12a1382faef", + "archive_bytes": 51424, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui.tar.gz b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui.tar.gz new file mode 100644 index 0000000..529f2b6 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_http_tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui-check.json b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui-check.json new file mode 100644 index 0000000..769e922 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "daf25aaf480559f4b9cfffa13ce91dcf1b84f008f0323ea59d00566dcfdc5d89", + "archive_bytes": 33761, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui.tar.gz b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui.tar.gz new file mode 100644 index 0000000..905c339 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-micro-package-boundary/cli_tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-micro-profile/default-host-budget.json b/prototypes/connections/results/2026-09-24-micro-profile/default-host-budget.json new file mode 100644 index 0000000..9ea33b6 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-micro-profile/default-host-budget.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35807, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "pure-connections": { + "sections": { + "text": 42761, + "data": 1040, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-mirror-source/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-mirror-source/pure-source-preview.tar.gz new file mode 100644 index 0000000..7f3ea7a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-mirror-source/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-mirror-source/source-check-linux-aarch64.json b/prototypes/connections/results/2026-09-24-mirror-source/source-check-linux-aarch64.json new file mode 100644 index 0000000..8fdefbb --- /dev/null +++ b/prototypes/connections/results/2026-09-24-mirror-source/source-check-linux-aarch64.json @@ -0,0 +1,15 @@ +{ + "archive_sha256": "7da3049ad80d1f54770524ba04583b5e2aef2e59e6d252d347947d32e7327f0b", + "archive_bytes": 332792, + "source_files": 170, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean selected page store exports mixed revisions to the default flat store", + "clean one-MiB document fans out to two same-process Pure domains with independent conflict and release", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-final.json b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-final.json new file mode 100644 index 0000000..253130f --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-final.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 135936, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-repeated.json b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-repeated.json new file mode 100644 index 0000000..541ec3e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan-repeated.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_rss_samples_kib": [362224, 385120], "large_window_rss_kib": 118016, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan.json b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan.json new file mode 100644 index 0000000..605d189 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-asan.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 142944, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-final.json b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-final.json new file mode 100644 index 0000000..4365549 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-final.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 43936, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-repeated.json b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-repeated.json new file mode 100644 index 0000000..7d36eca --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow-repeated.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_rss_samples_kib": [46672, 48960], "large_window_rss_kib": 43952, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow.json b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow.json new file mode 100644 index 0000000..4f6d78b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/gtk-flow.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 43952, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/hybrid-gtk-flow.json b/prototypes/connections/results/2026-09-24-multi-document-window/hybrid-gtk-flow.json new file mode 100644 index 0000000..8d132e5 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/hybrid-gtk-flow.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 44032, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-recheck.json b/prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-recheck.json new file mode 100644 index 0000000..dcae905 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-recheck.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "b72bc0b5321fa6efcc0ca8fbc3e328bc220333b0af134b1e75b7c5b517999a72", + "archive_bytes": 48934, + "selected_hosts": [ + "pure-edit", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted archived GTK window edits the same document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-repeated.json b/prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-repeated.json new file mode 100644 index 0000000..dcae905 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/legacy-package-repeated.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "b72bc0b5321fa6efcc0ca8fbc3e328bc220333b0af134b1e75b7c5b517999a72", + "archive_bytes": 48934, + "selected_hosts": [ + "pure-edit", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted archived GTK window edits the same document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/package-final.json b/prototypes/connections/results/2026-09-24-multi-document-window/package-final.json new file mode 100644 index 0000000..fd4343a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/package-final.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "59a5110ef5dd186c7ba9e5c3fbe74217c10c98014c2823aabf141d3f354f8dd6", + "archive_bytes": 49746, + "selected_hosts": [ + "pure-edit", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted GTK window switches documents without losing drafts" + ] +} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/package-repeated.json b/prototypes/connections/results/2026-09-24-multi-document-window/package-repeated.json new file mode 100644 index 0000000..fd4343a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/package-repeated.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "59a5110ef5dd186c7ba9e5c3fbe74217c10c98014c2823aabf141d3f354f8dd6", + "archive_bytes": 49746, + "selected_hosts": [ + "pure-edit", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted GTK window switches documents without losing drafts" + ] +} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/package.json b/prototypes/connections/results/2026-09-24-multi-document-window/package.json new file mode 100644 index 0000000..9616e80 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "40b7fef5e3202840794c91c5eb9c7e5592244dab443ce3597cc6fb9745b97746", + "archive_bytes": 49722, + "selected_hosts": [ + "pure-edit", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted GTK window and selected external host edit the same document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-final-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-final-linux-aarch64.tar.gz new file mode 100644 index 0000000..ded86dd Binary files /dev/null and b/prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-final-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-linux-aarch64.tar.gz new file mode 100644 index 0000000..1633c3d Binary files /dev/null and b/prototypes/connections/results/2026-09-24-multi-document-window/pure-document-window-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-fresh-buffer.json b/prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-fresh-buffer.json new file mode 100644 index 0000000..0e0b7be --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-fresh-buffer.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_rss_samples_kib": [48416, 49472, 51216, 52208, 54176, 55504, 57728, 59568], "large_window_rss_kib": 43968, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-initial.json b/prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-initial.json new file mode 100644 index 0000000..71658ed --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/switch-memory-initial.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_rss_samples_kib": [47328, 48048, 49888, 51744, 53840, 55824, 56864, 58752], "large_window_rss_kib": 43920, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-multi-document-window/window-profile.json b/prototypes/connections/results/2026-09-24-multi-document-window/window-profile.json new file mode 100644 index 0000000..d47571f --- /dev/null +++ b/prototypes/connections/results/2026-09-24-multi-document-window/window-profile.json @@ -0,0 +1,25 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-native-document-authoring", + "scope": "Review ceiling for the selected GTK document-and-definition host on Linux AArch64/GCC 14. Linked code sections and direct dependencies only; the measured 44 MiB whole-process RSS and shared GTK stack remain separate costs.", + "budget_sha256": "27ee292bf3fe9a15d27942f64cff01dfccde0ef5054acbb53faf76208d89d110", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-window": { + "sections": { + "text": 49882, + "data": 1504, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libgio-2.0.so.0", + "libglib-2.0.so.0", + "libgobject-2.0.so.0", + "libgtk-4.so.1" + ], + "sha256": "4d9806ce6f82413d17bc93452d9fd3bcc2b18272d15a2edaf81f8888e73b4e02" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-native-checkpoint/fixed-memory.json b/prototypes/connections/results/2026-09-24-native-checkpoint/fixed-memory.json new file mode 100644 index 0000000..84c7eb4 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-checkpoint/fixed-memory.json @@ -0,0 +1,43 @@ +{ + "passed": true, + "failures": [], + "profile": "portable-measurement-no-budget", + "variant": "fixed-program", + "scope": "Child-process peak RSS for native CLI document creation, read and one-byte revision edit; includes libc and the selected program form, excludes filesystem cache, kernel buffers, TLS, GUI and other apps. No cross-platform memory ceiling is asserted.", + "samples": { + "small_edit_rss_kib": 1104, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1072, + "one_mib_splice_rss_kib": 1104 + }, + "raw_samples": { + "small_edit_rss_kib": { + "exit_code": 0, + "max_rss_kib": 1104, + "raw_max_rss": 1104, + "raw_unit": "KiB" + }, + "one_mib_edit_rss_kib": { + "exit_code": 0, + "max_rss_kib": 2128, + "raw_max_rss": 2128, + "raw_unit": "KiB" + }, + "one_mib_show_rss_kib": { + "exit_code": 0, + "max_rss_kib": 1072, + "raw_max_rss": 1072, + "raw_unit": "KiB" + }, + "one_mib_splice_rss_kib": { + "exit_code": 0, + "max_rss_kib": 1104, + "raw_max_rss": 1104, + "raw_unit": "KiB" + } + }, + "budget_sha256": null, + "binary_sha256": "435e087adf62253078678b42c169426adcaddd514c78717e3be570f88dbcc248", + "binary_bytes": 135344, + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-native-checkpoint/fixed-tui.json b/prototypes/connections/results/2026-09-24-native-checkpoint/fixed-tui.json new file mode 100644 index 0000000..05a5d34 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-checkpoint/fixed-tui.json @@ -0,0 +1,24 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": null, + "one_mib_peak_rss_kib": null, + "one_thousand_key_list_peak_rss_kib": null, + "one_mib_peak_rss_limit_kib": 0, + "portable": true, + "terminal_locale": "C.UTF-8", + "binary_sha256": "d49431543f233d7979a3cdc07ec2c9e938acf014df6257f0165f76f30a0e248c" +} diff --git a/prototypes/connections/results/2026-09-24-native-checkpoint/source-memory.json b/prototypes/connections/results/2026-09-24-native-checkpoint/source-memory.json new file mode 100644 index 0000000..3f45eb1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-checkpoint/source-memory.json @@ -0,0 +1,43 @@ +{ + "passed": true, + "failures": [], + "profile": "portable-measurement-no-budget", + "variant": "source-program", + "scope": "Child-process peak RSS for native CLI document creation, read and one-byte revision edit; includes libc and the selected program form, excludes filesystem cache, kernel buffers, TLS, GUI and other apps. No cross-platform memory ceiling is asserted.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1056, + "one_mib_splice_rss_kib": 1104 + }, + "raw_samples": { + "small_edit_rss_kib": { + "exit_code": 0, + "max_rss_kib": 1056, + "raw_max_rss": 1056, + "raw_unit": "KiB" + }, + "one_mib_edit_rss_kib": { + "exit_code": 0, + "max_rss_kib": 2080, + "raw_max_rss": 2080, + "raw_unit": "KiB" + }, + "one_mib_show_rss_kib": { + "exit_code": 0, + "max_rss_kib": 1056, + "raw_max_rss": 1056, + "raw_unit": "KiB" + }, + "one_mib_splice_rss_kib": { + "exit_code": 0, + "max_rss_kib": 1104, + "raw_max_rss": 1104, + "raw_unit": "KiB" + } + }, + "budget_sha256": null, + "binary_sha256": "23ebc0ce1df9c49216511bee658fd5b9e373509e296bd2c2f8e76e6d3081cded", + "binary_bytes": 178336, + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-native-checkpoint/source-tui.json b/prototypes/connections/results/2026-09-24-native-checkpoint/source-tui.json new file mode 100644 index 0000000..0e2c5ad --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-checkpoint/source-tui.json @@ -0,0 +1,27 @@ +{ + "profile": "native-source-document-terminal-editor", + "checks": [ + "source parse and incompatible definitions fail before store creation", + "source CLI and TUI read the same document", + "changed source field layout drives TUI input encoding", + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": null, + "one_mib_peak_rss_kib": null, + "one_thousand_key_list_peak_rss_kib": null, + "one_mib_peak_rss_limit_kib": 0, + "portable": true, + "terminal_locale": "C.UTF-8", + "binary_sha256": "8e5ac649f2ab9a4dec02d32153f70aa13d97266cca1c9a942ba8fba27d04c8cf" +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/full-package-check.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/full-package-check.json new file mode 100644 index 0000000..05264e9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/full-package-check.json @@ -0,0 +1,20 @@ +{ + "archive_sha256": "831b049f8f4c38bc219bab7cd50138da862593e1e1c768b16705816438cac95f", + "archive_bytes": 100900, + "selected_hosts": [ + "pure-edit", + "pure-connections", + "pure-document-peer", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted HTTP host updates the same document as the native CLI", + "extracted C/Python TLS peers share a 1 MiB document and recover", + "extracted GTK window and selected external host edit the same document" + ], + "peer_idle_rss_kib": 7840, + "peer_after_large_work_rss_kib": 9520 +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow-final.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow-final.json new file mode 100644 index 0000000..4bac323 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow-final.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 43792, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow.json new file mode 100644 index 0000000..3372af3 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-flow.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 44032, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized-final.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized-final.json new file mode 100644 index 0000000..b65d2a2 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized-final.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 147696, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized.json new file mode 100644 index 0000000..fe540f2 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/gtk-sanitized.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 147952, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/http-only-package-check.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/http-only-package-check.json new file mode 100644 index 0000000..06ca49e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/http-only-package-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "a6c81aaf7ffef0cf1604e727f318d40bd51989ccb4b350793723ad3434d49148", + "archive_bytes": 47527, + "selected_hosts": [ + "pure-edit", + "pure-connections" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted HTTP host updates the same document as the native CLI" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/micro-memory.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/micro-memory.json new file mode 100644 index 0000000..2bf6a8d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/micro-memory.json @@ -0,0 +1,14 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1056, + "one_mib_edit_rss_kib": 2080, + "one_mib_show_rss_kib": 1104 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/micro-package-check.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/micro-package-check.json new file mode 100644 index 0000000..12dc47f --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/micro-package-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "b010450dfe11be3196e6b57071f9ac6b79e1fd79d5d5b2a314ea8780096ad7c6", + "archive_bytes": 22482, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/peer-only-package-check.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/peer-only-package-check.json new file mode 100644 index 0000000..acf8029 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/peer-only-package-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "18a171c2f1a7b75f4fe030d6a087d6c2f3471fca731e2c9d7f103940315852e5", + "archive_bytes": 49186, + "selected_hosts": [ + "pure-edit", + "pure-document-peer" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted C/Python TLS peers share a 1 MiB document and recover" + ], + "peer_idle_rss_kib": 7840, + "peer_after_large_work_rss_kib": 9520 +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-authoring-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-authoring-linux-aarch64.tar.gz new file mode 100644 index 0000000..0e64e74 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-authoring-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-http-only-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-http-only-linux-aarch64.tar.gz new file mode 100644 index 0000000..3c32eb4 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-http-only-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-micro-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-micro-linux-aarch64.tar.gz new file mode 100644 index 0000000..9b3fddc Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-micro-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-peer-only-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-peer-only-linux-aarch64.tar.gz new file mode 100644 index 0000000..d2b11d3 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-peer-only-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-window-only-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-window-only-linux-aarch64.tar.gz new file mode 100644 index 0000000..911348b Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-definition-authoring/pure-document-window-only-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/window-authoring.png b/prototypes/connections/results/2026-09-24-native-definition-authoring/window-authoring.png new file mode 100644 index 0000000..9a7945c Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-definition-authoring/window-authoring.png differ diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/window-budget.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/window-budget.json new file mode 100644 index 0000000..547b408 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/window-budget.json @@ -0,0 +1,25 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-native-document-authoring", + "scope": "Review ceiling for the selected GTK document-and-definition host on Linux AArch64/GCC 14. Linked code sections and direct dependencies only; the measured 44 MiB whole-process RSS and shared GTK stack remain separate costs.", + "budget_sha256": "27ee292bf3fe9a15d27942f64cff01dfccde0ef5054acbb53faf76208d89d110", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-window": { + "sections": { + "text": 48247, + "data": 1456, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libgio-2.0.so.0", + "libglib-2.0.so.0", + "libgobject-2.0.so.0", + "libgtk-4.so.1" + ], + "sha256": "b16b34dfb1b05bf6437b022992fa185787631734f76043149c410b0caf165895" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-native-definition-authoring/window-only-package-check.json b/prototypes/connections/results/2026-09-24-native-definition-authoring/window-only-package-check.json new file mode 100644 index 0000000..f0fa500 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-definition-authoring/window-only-package-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "b72bc0b5321fa6efcc0ca8fbc3e328bc220333b0af134b1e75b7c5b517999a72", + "archive_bytes": 48934, + "selected_hosts": [ + "pure-edit", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted GTK window and selected external host edit the same document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-document-window/build.json b/prototypes/connections/results/2026-09-24-native-document-window/build.json new file mode 100644 index 0000000..52bcb73 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/build.json @@ -0,0 +1,16 @@ +{ + "host": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "gtk": "4.18.6", + "binary_sha256": { + "build/pure-edit": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "build/pure-connections": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f", + "build/pure-edit-window": "97b12dbfb3af430bb13547d4d0f7791ab35a265fe9e22ca6265c1b4ec5d3165f" + }, + "sources": { + "edit-window.c": "b96687857e2fd66fc25d5ba8e3d91f06a3eae060a4161c30259174c15ee13603", + "check_window.py": "208cc9bb56123c7db0596dd1c97c6da0026f3bc9f0bb9f545f2cbb1acf30b712", + "Makefile": "d7fb2dce9f4b6854207eeb8b6b6e8b2dc294c6c1faf18bf3f486344834fae6d2", + "examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a" + } +} diff --git a/prototypes/connections/results/2026-09-24-native-document-window/connection-check.json b/prototypes/connections/results/2026-09-24-native-document-window/connection-check.json new file mode 100644 index 0000000..221db03 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/connection-check.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 41, + "commits": 12, + "gets": 14, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115416, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "07c328c98769d02d544aea2fc5389048a7620c00d9b3ea49b9c7ba45e33984b1", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "0e4ec2c78334af2056af376a6ba9aef7416a34c6d4fbd9082f4abb640e6de28c", + "editor_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03", + "binary_sha256": "e76d4c0a721c9ceb7142db0edbaa1f5c44bd78a2c55029658109c83e8076875f", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "44702e9414c26d1ee336b2e36e6713560013c2c8d0091a8900042e2468f88015", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "5d08639b5a71572553bda47cbd35562805d601aaa81f20731cd9154768ec025e", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "e1735316769b4ce4d5904b9587ccfa3ebeafa26517c53dba4491191d58c97b8e", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "0cb23dd0cff6bd0c9dc154f6f9ec253af72a23d93bf1a3797cd45ddd6091c9d0", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-native-document-window/core-budget.json b/prototypes/connections/results/2026-09-24-native-document-window/core-budget.json new file mode 100644 index 0000000..8c0caf7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/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/connections/results/2026-09-24-native-document-window/dependencies.txt b/prototypes/connections/results/2026-09-24-native-document-window/dependencies.txt new file mode 100644 index 0000000..e61b79d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/dependencies.txt @@ -0,0 +1,10 @@ +pure-edit +0x0000000000000001 (NEEDED) Shared library: [libc.so.6] +pure-connections +0x0000000000000001 (NEEDED) Shared library: [libc.so.6] +pure-edit-window +0x0000000000000001 (NEEDED) Shared library: [libgtk-4.so.1] +0x0000000000000001 (NEEDED) Shared library: [libgio-2.0.so.0] +0x0000000000000001 (NEEDED) Shared library: [libgobject-2.0.so.0] +0x0000000000000001 (NEEDED) Shared library: [libglib-2.0.so.0] +0x0000000000000001 (NEEDED) Shared library: [libc.so.6] diff --git a/prototypes/connections/results/2026-09-24-native-document-window/gtk-flow.json b/prototypes/connections/results/2026-09-24-native-document-window/gtk-flow.json new file mode 100644 index 0000000..1e4aa66 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/gtk-flow.json @@ -0,0 +1 @@ +{"empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "large_window_rss_kib": 44208, "one_mib_document_one_byte_edit": "pass", "profile": "optional-native-document-window", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-native-document-window/gtk-sanitized.json b/prototypes/connections/results/2026-09-24-native-document-window/gtk-sanitized.json new file mode 100644 index 0000000..babeafb --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/gtk-sanitized.json @@ -0,0 +1 @@ +{"empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "large_window_rss_kib": 143168, "one_mib_document_one_byte_edit": "pass", "profile": "optional-native-document-window", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-native-document-window/size.txt b/prototypes/connections/results/2026-09-24-native-document-window/size.txt new file mode 100644 index 0000000..dc609de --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/size.txt @@ -0,0 +1,6 @@ + text data bss dec hex filename + 5012 0 0 5012 1394 runtime.o (ex prototypes/connections/build/libpure-execution.a) + 920 0 0 920 398 evaluate.o (ex prototypes/connections/build/libpure-execution.a) + 35807 944 16 36767 8f9f prototypes/connections/build/pure-edit + 42761 1040 16 43817 ab29 prototypes/connections/build/pure-connections + 43308 1376 16 44700 ae9c prototypes/connections/build/pure-edit-window diff --git a/prototypes/connections/results/2026-09-24-native-document-window/stripped.txt b/prototypes/connections/results/2026-09-24-native-document-window/stripped.txt new file mode 100644 index 0000000..6eb0197 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-document-window/stripped.txt @@ -0,0 +1,3 @@ +pure-edit elf_bytes=177384 stripped_bytes=67904 +pure-connections elf_bytes=199080 stripped_bytes=68000 +pure-edit-window elf_bytes=199184 stripped_bytes=68280 diff --git a/prototypes/connections/results/2026-09-24-native-document-window/window.png b/prototypes/connections/results/2026-09-24-native-document-window/window.png new file mode 100644 index 0000000..809f57a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-document-window/window.png differ diff --git a/prototypes/connections/results/2026-09-24-native-save/fixed-cli-package-check.json b/prototypes/connections/results/2026-09-24-native-save/fixed-cli-package-check.json new file mode 100644 index 0000000..8e62057 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/fixed-cli-package-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "2f491df77c2d5c39c838f7235bcfcaadbd89b981e5b40624e624553a74604421", + "archive_bytes": 12966, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-save/fixed-package-check.json b/prototypes/connections/results/2026-09-24-native-save/fixed-package-check.json new file mode 100644 index 0000000..97dafe5 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/fixed-package-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "629dca525ec849fe9ab822ca5ff931477438534235ab00f7d34f873ee18b41e7", + "archive_bytes": 32072, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-save/flat-fixed-tui.json b/prototypes/connections/results/2026-09-24-native-save/flat-fixed-tui.json new file mode 100644 index 0000000..97fb4d7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/flat-fixed-tui.json @@ -0,0 +1,24 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1808, + "one_mib_peak_rss_kib": 2832, + "one_thousand_key_list_peak_rss_kib": 1856, + "one_mib_peak_rss_limit_kib": 4096, + "portable": false, + "terminal_locale": "C.UTF-8", + "binary_sha256": "e8b224e3ce5f5d83d597418d4027ec36dd44bfe84d17f6e9224135dc457316c1" +} diff --git a/prototypes/connections/results/2026-09-24-native-save/flat-package-check.json b/prototypes/connections/results/2026-09-24-native-save/flat-package-check.json new file mode 100644 index 0000000..f3db134 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/flat-package-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "0f8d1620cf36c968174687e7ca350d97d8f3101ce38bb70239b673ab104fe6f6", + "archive_bytes": 51117, + "selected_hosts": [ + "pure-edit", + "pure-edit-tui" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "relocated source-loaded TUI and CLI share UTF-8, large and many-document paths" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-save/flat-source-tui.json b/prototypes/connections/results/2026-09-24-native-save/flat-source-tui.json new file mode 100644 index 0000000..aa7d0b9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/flat-source-tui.json @@ -0,0 +1,27 @@ +{ + "profile": "native-source-document-terminal-editor", + "checks": [ + "source parse and incompatible definitions fail before store creation", + "source CLI and TUI read the same document", + "changed source field layout drives TUI input encoding", + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1824, + "one_mib_peak_rss_kib": 3216, + "one_thousand_key_list_peak_rss_kib": 1936, + "one_mib_peak_rss_limit_kib": 4096, + "portable": false, + "terminal_locale": "C.UTF-8", + "binary_sha256": "46bdfde2b8997213bf69c612bd63913330859fd46d77ecb9b7fca99e552ee143" +} diff --git a/prototypes/connections/results/2026-09-24-native-save/hybrid-fixed-tui.json b/prototypes/connections/results/2026-09-24-native-save/hybrid-fixed-tui.json new file mode 100644 index 0000000..5b5e97c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/hybrid-fixed-tui.json @@ -0,0 +1,24 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1840, + "one_mib_peak_rss_kib": 3184, + "one_thousand_key_list_peak_rss_kib": 1904, + "one_mib_peak_rss_limit_kib": 4096, + "portable": false, + "terminal_locale": "C.UTF-8", + "binary_sha256": "09303ec33d07c12b07834b07fb5eeb836005c23fcb3a6b6cc2683631a9d4b13c" +} diff --git a/prototypes/connections/results/2026-09-24-native-save/hybrid-source-tui.json b/prototypes/connections/results/2026-09-24-native-save/hybrid-source-tui.json new file mode 100644 index 0000000..2da02ec --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/hybrid-source-tui.json @@ -0,0 +1,27 @@ +{ + "profile": "native-source-document-terminal-editor", + "checks": [ + "source parse and incompatible definitions fail before store creation", + "source CLI and TUI read the same document", + "changed source field layout drives TUI input encoding", + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1824, + "one_mib_peak_rss_kib": 3200, + "one_thousand_key_list_peak_rss_kib": 1936, + "one_mib_peak_rss_limit_kib": 4096, + "portable": false, + "terminal_locale": "C.UTF-8", + "binary_sha256": "a3812efdf7d22cc98369eb878b91a1e5341d5bed18fd99b9fe4264177fd41973" +} diff --git a/prototypes/connections/results/2026-09-24-native-save/package-check.json b/prototypes/connections/results/2026-09-24-native-save/package-check.json new file mode 100644 index 0000000..e57b8ca --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/package-check.json @@ -0,0 +1,15 @@ +{ + "archive_sha256": "b3c156eb7880ee4cb91b3fe52854cb5069ef035d082b7d539fa2a79592247ec5", + "archive_bytes": 57788, + "selected_hosts": [ + "pure-edit", + "pure-edit-tui" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "selected storage keeps small values inline and maps large values", + "editing the packaged .pure source changes the executed reply", + "relocated source-loaded TUI and CLI share UTF-8, large and many-document paths" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..9f81ca0 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..5114f58 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-save/pure-document-fixed-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-save/pure-document-flat-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-save/pure-document-flat-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..59abe7f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-save/pure-document-flat-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-save/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-native-save/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..313c49d Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-save/pure-document-hybrid-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-save/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-native-save/pure-source-preview.tar.gz new file mode 100644 index 0000000..9f29727 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-save/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-save/source-package-check.json b/prototypes/connections/results/2026-09-24-native-save/source-package-check.json new file mode 100644 index 0000000..4c93ea8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-save/source-package-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "b258788cc9f90d8e6eccc964c8c9f4e2c5460df3dfbb9da07268da07799691e4", + "archive_bytes": 333925, + "source_files": 171, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean selected page store exports mixed revisions to the default flat store", + "clean one-MiB document fans out to two same-process Pure domains with independent conflict and release", + "clean selected page store runs the source-loaded TUI and relocatable CLI+TUI package", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-native-source/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-native-source/pure-source-preview.tar.gz new file mode 100644 index 0000000..40d2e5e Binary files /dev/null and b/prototypes/connections/results/2026-09-24-native-source/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-native-source/source-check-linux-aarch64.json b/prototypes/connections/results/2026-09-24-native-source/source-check-linux-aarch64.json new file mode 100644 index 0000000..8fc4225 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-native-source/source-check-linux-aarch64.json @@ -0,0 +1,13 @@ +{ + "archive_sha256": "39a450a8a2e2cbfad39b95b9e91ede9899f2530cf9574ebff5d7b3748b9b0c3a", + "archive_bytes": 325436, + "source_files": 166, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..26c92da Binary files /dev/null and b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..b20331e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-linux-aarch64.tar.gz.check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "8a6dc8691eca1eb0a2b6daaccfd9e58c64777e5b42a4bb73c4b21b72375b880c", + "archive_bytes": 12886, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..f208ccf Binary files /dev/null and b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..2ca530a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-fixed-cli-tui-linux-aarch64.tar.gz.check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "5cb94eed37f7c7acbaad85d20715f2cc42d84d5d0de3af4788db284c9ede3e9e", + "archive_bytes": 31965, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..b026d9a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..fcc2a79 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-linux-aarch64.tar.gz.check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "d83048e6804252857ae80b6ee843fe8c9346802b9f1f9df30188845338e227af", + "archive_bytes": 22655, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..101a1fa Binary files /dev/null and b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json new file mode 100644 index 0000000..1f72434 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-opaque-core/pure-document-source-cli-tui-linux-aarch64.tar.gz.check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "6f7a534dca12bb670cb4a4f6d28e3d9e569f0667015415c3c0473b5a0f909361", + "archive_bytes": 51005, + "selected_hosts": [ + "pure-edit", + "pure-edit-tui" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "relocated source-loaded TUI and CLI share UTF-8, large and many-document paths" + ] +} diff --git a/prototypes/connections/results/2026-09-24-opaque-core/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-opaque-core/pure-source-preview.tar.gz new file mode 100644 index 0000000..ce3e1b1 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-opaque-core/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-opaque-core/source-package-check.json b/prototypes/connections/results/2026-09-24-opaque-core/source-package-check.json new file mode 100644 index 0000000..898639f --- /dev/null +++ b/prototypes/connections/results/2026-09-24-opaque-core/source-package-check.json @@ -0,0 +1,15 @@ +{ + "archive_sha256": "c0c3983f25088eb75ccf621cee264c210ea99d888f675e3bbb2ca2c59ea3246e", + "archive_bytes": 334682, + "source_files": 171, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean selected page store exports mixed revisions to the default flat store", + "clean one-MiB document fans out to two same-process Pure domains with independent conflict and release", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/app-asan.json b/prototypes/connections/results/2026-09-24-paged-storage/app-asan.json new file mode 100644 index 0000000..121a548 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/app-asan.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11679, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 2, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 114, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "52e81f7277f11cddc8178a6be1f0693a39210445b7d338e3907c50c2f696f6b6", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "4e4bf4bd1f107510521703d49b207cad40b4d05e4d391fb997a34c1128cf2739", + "editor_sha256": "d2e3720a54597506cbe1262fdd6ef380a7bf3a7e7ef9012f4ae7dabf07fc781d", + "binary_sha256": "14e8472baf7c536c40dfbd1197b4e8be03099d1829e361456370ff678bb31cb6", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store-paged.c": "98ac90368b5be8fba5f9cd998145739d13c0003b822f048e4acbc517228e147e", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "26c44763b13cbe80800ece1c6f1ab6c330d1d201edb9ea421e67cf24e8546fd2", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/app-release.json b/prototypes/connections/results/2026-09-24-paged-storage/app-release.json new file mode 100644 index 0000000..bdaf2f7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/app-release.json @@ -0,0 +1,635 @@ +{ + "kind": "prepared-connection-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "writer contention and invalid extent fail without overwriting data", + "two native writers cannot both create the same expected revision", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines", + "authored nested read maps only final demand while preserving parent bounds and revision", + "unused checked view still fails even when response range would be valid", + "authored write and reply share selected input; invalid parent prevents the storage effect", + "body-free response preserves read existence and malformed-object errors", + "input-backed response survives partial send and releases on finish or abort without undoing commit", + "invalid authored program fails preparation before creating storage or accepting requests", + "text client uses character selection; named numeric fields select and persist the matching UTF-8 bytes", + "truncated layouts and invalid data-dependent ranges never change storage; empty and end selections remain valid", + "changed declaration order and unaligned numeric fields determine access; aliases add no dynamic steps", + "unused dynamic field selection retains its validation before the write effect", + "named variable tail reads use the same file version and charge only the selected suffix", + "data-dependent response keeps and charges the original input through partial transmission", + "native authored byte edit and fresh native process restore UTF-8 content", + "native stale revision and invalid byte position preserve the committed document", + "native convenience editor derives unaligned reordered field positions from the authored program", + "raw native apply runs another authored input contract without changing the executor", + "native CLI and external HTTP share revisions and the same authored storage effect in both directions", + "partial or invalid edit input never modifies the stored document", + "two processes cannot both splice the same expected document revision", + "1 MiB document changes with 11 input credits; larger input is rejected before an effect" + ], + "prepared_connections": { + "excerpt": { + "read": { + "to_end": false, + "offset": 32, + "length": 16, + "minimum_source_bytes": 80 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 2, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "write": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 5, + "length": 4, + "minimum_source_bytes": 12 + }, + "reply_input": true, + "view_definitions": 3, + "range_definitions": 2, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "field_connections": { + "selection": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 8 + }, + "reply_input": true, + "view_definitions": 2, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 8, + "length": 0, + "to_end": true + }, + "offset": { + "value": 0, + "from_u32le": true + }, + "length": { + "value": 4, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "reordered": { + "read": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "write": { + "to_end": false, + "offset": 0, + "length": 0, + "minimum_source_bytes": 11 + }, + "reply_input": true, + "view_definitions": 4, + "range_definitions": 1, + "runtime_view_steps": 1, + "write_step": 1, + "reply_step": 1, + "prepared_program_bytes": 680, + "steps": [ + { + "source_step": 0, + "source": { + "offset": 11, + "length": 0, + "to_end": true + }, + "offset": { + "value": 7, + "from_u32le": true + }, + "length": { + "value": 3, + "from_u32le": true + } + } + ], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + }, + "field_get": { + "read": { + "to_end": true, + "offset": 11, + "length": 0, + "minimum_source_bytes": 11 + }, + "write": { + "to_end": true, + "offset": 0, + "length": 0, + "minimum_source_bytes": 0 + }, + "reply_input": false, + "view_definitions": 1, + "range_definitions": 0, + "runtime_view_steps": 0, + "write_step": 0, + "reply_step": 0, + "prepared_program_bytes": 680, + "steps": [], + "write_effect": "write", + "splice_offset": { + "value": 0, + "from_u32le": false + }, + "splice_remove": { + "value": 0, + "from_u32le": false + } + } + }, + "document_edits": { + "large_document_bytes": 1048576, + "small_edit_input_bytes": 11, + "storage_rewrites_full_extent": true, + "storage_scratch_bytes": 4096, + "kind": "correctness-and-admission-not-total-memory-or-performance" + }, + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 6, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 0, + "gets": 0, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 6, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 42, + "commits": 12, + "gets": 15, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 32, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115420, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 80, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 2, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 16, + "payload_budget": 16, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 16, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 12, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 5, + "commits": 1, + "gets": 2, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 12, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 0, + "payload_budget": 1, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 0, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 0, + "budget_rejections": 2, + "expired": 0, + "releases": 5, + "ingress_prefix_copy_bytes": 8006, + "borrowed_output_bytes": 3145734, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 54, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 20, + "commits": 3, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 17, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 57, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 19, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 8, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 14, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 4, + "commits": 1, + "gets": 1, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 34, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 3, + "commits": 3, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 0, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 23, + "payload_budget": 23, + "peak_connections": 1, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 23, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 1048584, + "peak_connections": 2, + "accepted": 4, + "commits": 1, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 3, + "ingress_prefix_copy_bytes": 3998, + "borrowed_output_bytes": 2097091, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 9, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 1, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 29, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 10, + "commits": 1, + "gets": 4, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 10, + "ingress_prefix_copy_bytes": 7, + "borrowed_output_bytes": 113, + "server_structure_bytes": 41528 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 11, + "payload_budget": 11, + "peak_connections": 1, + "accepted": 2, + "commits": 1, + "gets": 0, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 1, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 3, + "server_structure_bytes": 41528 + } + ], + "storage": "immutable-byte-extents-v1", + "execution_core": { + "runtime_tests": "passed", + "domain_bytes": 200, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "forwarding_chain": 1024, + "native_storage_pipeline": true + }, + "execution_core_test_sha256": "ecc89dcd0868ae701fa3d3ffbebae1f90447899d7802441a0062adc45e4f171f", + "document_edit_units": { + "splice_tests": "passed", + "checks": [ + "authored-fields-and-atomic-parse-failure", + "binary-edit-oracle-and-old-snapshot", + "bounds-conflict-size-overflow", + "publication-failures-and-uncertain-durability", + "cancel-admission-and-borrowed-reply" + ] + }, + "document_edit_test_sha256": "2d230f27ab5e3e6cd4d65d52015e456995aabc55b50ef665880755b9d01cca5d", + "editor_sha256": "497577bf9811b3ce787e27c6170a322ea553cdab2371b4f71eb51115a9045a16", + "binary_sha256": "b3df35ce2e25dc00578c79e47f6c5a21334da31df38cd482d9e71b835597f9cf", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/store-paged.c": "98ac90368b5be8fba5f9cd998145739d13c0003b822f048e4acbc517228e147e", + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.h": "e3cf5f169be02e40f1274105046f29a63a46cd153beaf204d33d6100b9c869b8", + "prototypes/connections/program.c": "d7186634d2bfd83bbaf87ea3bafdec8d14be8f556cbf3d9877d07e0074b4b389", + "prototypes/connections/program.h": "b9ea0af3759150637ccba9362ccb73be374ed85b449b0461fc61fb14ee50e8bc", + "prototypes/connections/runtime.c": "a6c068c6a2cee214b3fe30241459394833917dfb6c0d33052460eab4c6a539a2", + "prototypes/connections/runtime.h": "ff03c0be92dcc482247a58a0be54d2dc5c270325b51ceeed37afc2ffa5480b1f", + "prototypes/connections/result.h": "00b6cf02262039fd90b197eb781f38c9771beaa8d65f394cba6d3f8d0b2ae79d", + "prototypes/connections/evaluate.c": "ee8b91efc733b9d305711b7cbb7b502ae5d3917e06f750b7f62b632ed0ea6d04", + "prototypes/connections/store-port.c": "d6bf17878b325e039360816edcbf8d68e41b373cf3e6128d7795e11ae21fdd6f", + "prototypes/connections/store-port.h": "a262bac93463bfe9939ca0bdc5989d1665aafc007d61e93c28ffc36478b1fde6", + "prototypes/connections/test-runtime.c": "58526592ee906833718101f1d33878d66c5e357097d5347d02755d922c36dda9", + "prototypes/connections/server.c": "76645bafe8e4e429155efe96c2d05a59aed506ba89736283e4fdae71a3d9a60f", + "prototypes/connections/check.py": "546ab85ba3495daa09a6299b23e05308718287b77bde326849ce9c26c8068eb2", + "prototypes/connections/check_edits.py": "7699eb4bda57ad860acf7be6da3e6de56d6b9a3dcf3b9ff9c7df104f5bcb9224", + "prototypes/connections/edit.c": "793d0bde277563c9e11f1225804fb1646e4ce495979d16e8d70d00322a7bec7b", + "prototypes/connections/test-splice.c": "2415188c89ff413f10d55a8cf1902e76c585cc4102f01d504bbf84dbe8d90a8f", + "prototypes/connections/test-flow.c": "2cd2618015ba6806b1a733af0fc7d0ed6aec48fdec117d18c3a8160f534e130b", + "prototypes/connections/test-program.c": "a1b65be05ab2e4e5ccbc56cd1e03e17b0d885f15dc027bf295371ec821e19cc8", + "prototypes/connections/test-fields.c": "e5856ae1985dc8407565ebf7b65702478a34a05081c955f127a10d3cdc900507", + "prototypes/connections/Makefile": "26c44763b13cbe80800ece1c6f1ab6c330d1d201edb9ea421e67cf24e8546fd2", + "prototypes/connections/core-layout.c": "e802b4976c53755ffc045ae9114ebdcf2989d526d8c871c3cfe89d057f4b82b0", + "prototypes/connections/check_core.py": "f80ead11764e870f0bde434c93233d518e5874fdb32f77444e7bd8bbe7398a10", + "prototypes/connections/core-budget.json": "b4eceaffce900144470dd29076164039b8729bad4ba849789d212e7c952f5082", + "prototypes/connections/examples/editor.pure": "8ca6c191b98bf53a592ce24f2b5cd040e41f05c4ccdda7a072d5d7f79870c06a", + "prototypes/connections/examples/storage.pure": "6f2fd3733f46b762836405f40a9720de0b865f92a8bcbf9388dda0532c903c74", + "prototypes/connections/examples/excerpt.pure": "7ca1b9a4b6a06c32b95e37bcdeb2202ef912ffbf16163f7eea2b00574c703c16", + "prototypes/connections/examples/selection.pure": "90a19a080dd31b532163b8063057beee4701d8f74163ac4083833a1d664fe986", + "prototypes/connections/examples/select-text.py": "3211ce21bf6058f0131544959272587af4edb3fd5a956aee0a940ab19888f944", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "e5187c44f8d2df952a85e4cf8102b71dbec6c43420893b3544a6f8514a1bff5c", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/gtk-flow.json b/prototypes/connections/results/2026-09-24-paged-storage/gtk-flow.json new file mode 100644 index 0000000..4a58c4b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/gtk-flow.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_window_rss_kib": 44656, "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/memory.json b/prototypes/connections/results/2026-09-24-paged-storage/memory.json new file mode 100644 index 0000000..512da26 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/memory.json @@ -0,0 +1,14 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-native-document-cli-memory", + "scope": "Peak process RSS via a small fork/wait4 helper on Linux AArch64. Covers this CLI, libc, program parsing, store, and one 1 MiB document workload; excludes filesystem cache, kernel buffers, other processes, selected TLS/GTK hosts, and other applications. These are regression ceilings for this toolchain, not a universal memory guarantee.", + "samples": { + "small_edit_rss_kib": 1104, + "one_mib_edit_rss_kib": 2128, + "one_mib_show_rss_kib": 1056 + }, + "budget_sha256": "53144cabe646e0478f25f4e770ce01bad06f4984ef47f5160d212c02c0b7569f", + "binary_sha256": "497577bf9811b3ce787e27c6170a322ea553cdab2371b4f71eb51115a9045a16", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41" +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/package-full.json b/prototypes/connections/results/2026-09-24-paged-storage/package-full.json new file mode 100644 index 0000000..88f0795 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/package-full.json @@ -0,0 +1,20 @@ +{ + "archive_sha256": "08565ad139a5f9ad8ab52f96adde37dc092524e9bf699d9441787c489d8d11f1", + "archive_bytes": 111765, + "selected_hosts": [ + "pure-edit", + "pure-connections", + "pure-document-peer", + "pure-edit-window" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted HTTP host updates the same document as the native CLI", + "extracted C/Python TLS peers share a 1 MiB document and recover", + "extracted GTK window and selected external host edit the same document" + ], + "peer_idle_rss_kib": 7856, + "peer_after_large_work_rss_kib": 9552 +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/package-micro.json b/prototypes/connections/results/2026-09-24-paged-storage/package-micro.json new file mode 100644 index 0000000..552cea9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/package-micro.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "b247ba1a453c2500ec41e9161e130ec9a3b6c9316910fe655920d6fcfb62ec45", + "archive_bytes": 25456, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/paged-profile.json b/prototypes/connections/results/2026-09-24-paged-storage/paged-profile.json new file mode 100644 index 0000000..d898c44 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/paged-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-paged-document-storage", + "scope": "Selected 16 KiB-page document storage backend on Linux AArch64/GCC 14. Same linked-code and direct-dependency ceilings as the flat backend; excludes libc, process RAM, filesystem cache and journal, and optional TLS/GTK. Any failure requires review, not automatic relaxation.", + "budget_sha256": "728cbe5488a86c7cb588a1b9e53d5ddc8aafe617c229624d7abe3e3a68f50699", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 39960, + "data": 1000, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "497577bf9811b3ce787e27c6170a322ea553cdab2371b4f71eb51115a9045a16" + }, + "pure-connections": { + "sections": { + "text": 46824, + "data": 1088, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "b3df35ce2e25dc00578c79e47f6c5a21334da31df38cd482d9e71b835597f9cf" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/peer.json b/prototypes/connections/results/2026-09-24-paged-storage/peer.json new file mode 100644 index 0000000..fa238e3 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/peer.json @@ -0,0 +1,16 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7840, + "server_after_large_work_rss_kib": 9536 +} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-authoring-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-authoring-linux-aarch64.tar.gz new file mode 100644 index 0000000..259dac1 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-authoring-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-micro-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-micro-linux-aarch64.tar.gz new file mode 100644 index 0000000..ed06d92 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-paged-storage/pure-document-paged-micro-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-paged-storage/unit-asan.json b/prototypes/connections/results/2026-09-24-paged-storage/unit-asan.json new file mode 100644 index 0000000..697b69c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/unit-asan.json @@ -0,0 +1 @@ +{"paged_storage":"snapshot-patch-range-compaction-crash-conflict-uncertain","mixed_edits":128} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/unit.json b/prototypes/connections/results/2026-09-24-paged-storage/unit.json new file mode 100644 index 0000000..697b69c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/unit.json @@ -0,0 +1 @@ +{"paged_storage":"snapshot-patch-range-compaction-crash-conflict-uncertain","mixed_edits":128} diff --git a/prototypes/connections/results/2026-09-24-paged-storage/write-cost.json b/prototypes/connections/results/2026-09-24-paged-storage/write-cost.json new file mode 100644 index 0000000..f3197a8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-paged-storage/write-cost.json @@ -0,0 +1,49 @@ +{ + "profile": "linux-aarch64-16k-page-document-replacement", + "workloads": [ + "replace last byte of 1 MiB document", + "replace one-byte document" + ], + "unit": "successful user-space write syscall bytes targeting object files", + "excludes": "physical device writes, filesystem journal, metadata and directory sync cost", + "one_mib": { + "flat": { + "object_write_syscall_bytes": 1048600, + "object_write_calls": 258, + "object_files": { + "obj-large": 1048600 + }, + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "paged": { + "object_write_syscall_bytes": 16944, + "object_write_calls": 3, + "object_files": { + "obj-large": 560, + "dat-large-0000000000000001": 1064960 + }, + "binary_sha256": "497577bf9811b3ce787e27c6170a322ea553cdab2371b4f71eb51115a9045a16" + }, + "flat_to_paged_ratio": 61.8862134088763 + }, + "one_byte": { + "flat": { + "object_write_syscall_bytes": 25, + "object_write_calls": 2, + "object_files": { + "obj-tiny": 25 + }, + "binary_sha256": "b5c6ec82a1e9c8a1391d1c4ac680e7f33e53dafe5c29ac8a956d5097653b1a03" + }, + "paged": { + "object_write_syscall_bytes": 16440, + "object_write_calls": 3, + "object_files": { + "obj-tiny": 56, + "dat-tiny-0000000000000001": 32768 + }, + "binary_sha256": "497577bf9811b3ce787e27c6170a322ea553cdab2371b4f71eb51115a9045a16" + }, + "paged_to_flat_ratio": 657.6 + } +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer-source/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-portable-peer-source/pure-source-preview.tar.gz new file mode 100644 index 0000000..ac8ed09 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-portable-peer-source/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-portable-peer-source/source-check-linux-aarch64.json b/prototypes/connections/results/2026-09-24-portable-peer-source/source-check-linux-aarch64.json new file mode 100644 index 0000000..0b54aff --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer-source/source-check-linux-aarch64.json @@ -0,0 +1,15 @@ +{ + "archive_sha256": "bf51f79c400f820b48dbf78522449cda6625563ce860809e86d76a8023377dbb", + "archive_bytes": 321958, + "source_files": 165, + "checks": [ + "every extracted source file matches the manifest", + "clean C17 build and host-neutral behavior tests pass", + "clean fixed and source-loaded native terminal PTY scenarios pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean native TLS client and both server profiles pass authenticated document work", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-aarch64.json b/prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-aarch64.json new file mode 100644 index 0000000..b967ae6 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-aarch64.json @@ -0,0 +1,17 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": null, + "server_after_large_work_rss_kib": null, + "portable": true +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-rss-regression.json b/prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-rss-regression.json new file mode 100644 index 0000000..635361a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer/client-server-linux-rss-regression.json @@ -0,0 +1,17 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7968, + "server_after_large_work_rss_kib": 9712, + "portable": false +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer/client-server-profile.json b/prototypes/connections/results/2026-09-24-portable-peer/client-server-profile.json new file mode 100644 index 0000000..f8e8511 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer/client-server-profile.json @@ -0,0 +1,23 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-document-peer", + "scope": "Review threshold for the explicitly selected authenticated document host on Linux AArch64/GCC 14. Includes linked application code but excludes the shared OpenSSL/libc implementations, process heap, stack, mappings and kernel buffers. Check measured whole-process RSS separately.", + "budget_sha256": "defe8efb4d544cdbf615de4d43d4ceba78fc7f1d958f245731202625052eb2ff", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-document-peer": { + "sections": { + "text": 46934, + "data": 1384, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libcrypto.so.3", + "libssl.so.3" + ], + "sha256": "06fc08105cb9833599b9c778797cf2be4ba8a6c1db514c836bb76667a8c7265e" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-aarch64.json b/prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-aarch64.json new file mode 100644 index 0000000..b967ae6 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-aarch64.json @@ -0,0 +1,17 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": null, + "server_after_large_work_rss_kib": null, + "portable": true +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-rss-regression.json b/prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-rss-regression.json new file mode 100644 index 0000000..635361a --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer/server-only-linux-rss-regression.json @@ -0,0 +1,17 @@ +{ + "profile": "selected-authenticated-document-peer", + "checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "server_idle_rss_kib": 7968, + "server_after_large_work_rss_kib": 9712, + "portable": false +} diff --git a/prototypes/connections/results/2026-09-24-portable-peer/server-only-profile.json b/prototypes/connections/results/2026-09-24-portable-peer/server-only-profile.json new file mode 100644 index 0000000..0f6a3ce --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-peer/server-only-profile.json @@ -0,0 +1,23 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-selected-document-server", + "scope": "Reviewed server-only TLS peer ceiling on Linux AArch64/GCC 14. Excludes linked native client code and shared OpenSSL/libc implementations; process RSS is measured separately.", + "budget_sha256": "deae8827323061543193779ada90d1bca4db3a6dda80293e4ad28a9526be9819", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-document-server": { + "sections": { + "text": 43462, + "data": 1352, + "bss": 16 + }, + "needed": [ + "libc.so.6", + "libcrypto.so.3", + "libssl.so.3" + ], + "sha256": "728025cfbdbcdb6d212a2ec8297a0740967653c77ae8908d923c59ddd4ea0315" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-portable-source/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-portable-source/pure-source-preview.tar.gz new file mode 100644 index 0000000..8ef47db Binary files /dev/null and b/prototypes/connections/results/2026-09-24-portable-source/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-portable-source/source-check-linux-aarch64.json b/prototypes/connections/results/2026-09-24-portable-source/source-check-linux-aarch64.json new file mode 100644 index 0000000..57a1027 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-source/source-check-linux-aarch64.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "5f1766fefe2c319d86c3ff57c881638a9d1ee24374dea33c614e25577d087814", + "archive_bytes": 321399, + "source_files": 165, + "checks": [ + "every extracted source file matches the manifest", + "clean C17 build and host-neutral behavior tests pass", + "clean fixed and source-loaded native terminal PTY scenarios pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-aarch64.json b/prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-aarch64.json new file mode 100644 index 0000000..3e0fe7d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-aarch64.json @@ -0,0 +1,24 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": null, + "one_mib_peak_rss_kib": null, + "one_thousand_key_list_peak_rss_kib": null, + "one_mib_peak_rss_limit_kib": 0, + "portable": true, + "terminal_locale": "C.UTF-8", + "binary_sha256": "45491c3e28193024d6eb77cbd6569ee8b7470d11ec4126ff4515dba116790308" +} diff --git a/prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-rss-regression.json b/prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-rss-regression.json new file mode 100644 index 0000000..19090b0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-tui/fixed-linux-rss-regression.json @@ -0,0 +1,24 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1776, + "one_mib_peak_rss_kib": 3168, + "one_thousand_key_list_peak_rss_kib": 1888, + "one_mib_peak_rss_limit_kib": 4096, + "portable": false, + "terminal_locale": "C.UTF-8", + "binary_sha256": "45491c3e28193024d6eb77cbd6569ee8b7470d11ec4126ff4515dba116790308" +} diff --git a/prototypes/connections/results/2026-09-24-portable-tui/source-linux-aarch64.json b/prototypes/connections/results/2026-09-24-portable-tui/source-linux-aarch64.json new file mode 100644 index 0000000..53b3218 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-tui/source-linux-aarch64.json @@ -0,0 +1,27 @@ +{ + "profile": "native-source-document-terminal-editor", + "checks": [ + "source parse and incompatible definitions fail before store creation", + "source CLI and TUI read the same document", + "changed source field layout drives TUI input encoding", + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": null, + "one_mib_peak_rss_kib": null, + "one_thousand_key_list_peak_rss_kib": null, + "one_mib_peak_rss_limit_kib": 0, + "portable": true, + "terminal_locale": "C.UTF-8", + "binary_sha256": "e4fb890935f77a7b359163758ce4c44f3710d1a99130a7d883a4f7329a20a70d" +} diff --git a/prototypes/connections/results/2026-09-24-portable-tui/source-linux-rss-regression.json b/prototypes/connections/results/2026-09-24-portable-tui/source-linux-rss-regression.json new file mode 100644 index 0000000..b112863 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-portable-tui/source-linux-rss-regression.json @@ -0,0 +1,27 @@ +{ + "profile": "native-source-document-terminal-editor", + "checks": [ + "source parse and incompatible definitions fail before store creation", + "source CLI and TUI read the same document", + "changed source field layout drives TUI input encoding", + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1856, + "one_mib_peak_rss_kib": 3664, + "one_thousand_key_list_peak_rss_kib": 1936, + "one_mib_peak_rss_limit_kib": 4096, + "portable": false, + "terminal_locale": "C.UTF-8", + "binary_sha256": "e4fb890935f77a7b359163758ce4c44f3710d1a99130a7d883a4f7329a20a70d" +} diff --git a/prototypes/connections/results/2026-09-24-posix-idle/fixed-profile.json b/prototypes/connections/results/2026-09-24-posix-idle/fixed-profile.json new file mode 100644 index 0000000..b56deb9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-posix-idle/fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8" + }, + "pure-connections-fixed": { + "sections": { + "text": 26261, + "data": 1028, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "1999ad534e9091fbaca096402ba2fbeaeb92c9fb4d4150cacaf309e9a9cd9555" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-posix-idle/fixed-server-regression.json b/prototypes/connections/results/2026-09-24-posix-idle/fixed-server-regression.json new file mode 100644 index 0000000..d96f70c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-posix-idle/fixed-server-regression.json @@ -0,0 +1,68 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 11, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_idle_rss_kib": 1520, + "source_idle_rss_kib": 1552, + "fixed_after_large_rss_kib": 1648, + "source_after_large_rss_kib": 1680, + "fixed_peak_rss_kib": 2144, + "source_peak_rss_kib": 1680, + "fixed_peak_rss_limit_kib": 4096, + "fixed_sha256": "1999ad534e9091fbaca096402ba2fbeaeb92c9fb4d4150cacaf309e9a9cd9555", + "source_sha256": "1d4e858f18e7cea6df263e2d716898ba7a15b966ecda7e1145b21b80f8fe8f88" +} diff --git a/prototypes/connections/results/2026-09-24-posix-idle/package.json b/prototypes/connections/results/2026-09-24-posix-idle/package.json new file mode 100644 index 0000000..5085bdd --- /dev/null +++ b/prototypes/connections/results/2026-09-24-posix-idle/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "47f8a4943187edc3733f5900790d5c240d25e2042a0b6c3567eb5da2ece68dd1", + "archive_bytes": 32346, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-posix-idle/pure-document-fixed-http-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-posix-idle/pure-document-fixed-http-linux-aarch64.tar.gz new file mode 100644 index 0000000..8b50236 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-posix-idle/pure-document-fixed-http-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-posix-idle/reproducible-comparison.json b/prototypes/connections/results/2026-09-24-posix-idle/reproducible-comparison.json new file mode 100644 index 0000000..7548186 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-posix-idle/reproducible-comparison.json @@ -0,0 +1,21 @@ +{ + "profile": "idle-http-host-poll-syscalls", + "observation_seconds": 1.25, + "before": { + "poll_calls": 25, + "timeout_values_ms": [ + 50 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=PO", + "binary_sha256": "7dd0010fa6d6024afa2fe35d57a4c73f33ac632e732616ac8f8853d7b22d79b3" + }, + "after": { + "poll_calls": 1, + "timeout_values_ms": [ + -1 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=4, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 10, NULL, NULL, 0) = ? ERESTARTNOHAND (To be restarted if no handler)\n--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=941369, si_uid=1000} ---\n", + "binary_sha256": "1999ad534e9091fbaca096402ba2fbeaeb92c9fb4d4150cacaf309e9a9cd9555" + }, + "deadline_driven_idle_wait": "pass" +} diff --git a/prototypes/connections/results/2026-09-24-posix-idle/service-scale.json b/prototypes/connections/results/2026-09-24-posix-idle/service-scale.json new file mode 100644 index 0000000..c1396ab --- /dev/null +++ b/prototypes/connections/results/2026-09-24-posix-idle/service-scale.json @@ -0,0 +1,62 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "1999ad534e9091fbaca096402ba2fbeaeb92c9fb4d4150cacaf309e9a9cd9555", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1504, + "peak_rss_kib": 1680, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49185, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 326623, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "1d4e858f18e7cea6df263e2d716898ba7a15b966ecda7e1145b21b80f8fe8f88", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1552, + "peak_rss_kib": 1712, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49168, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 366236, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-posix-idle/source-profile.json b/prototypes/connections/results/2026-09-24-posix-idle/source-profile.json new file mode 100644 index 0000000..d5795da --- /dev/null +++ b/prototypes/connections/results/2026-09-24-posix-idle/source-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35807, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7" + }, + "pure-connections": { + "sections": { + "text": 43778, + "data": 1068, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "1d4e858f18e7cea6df263e2d716898ba7a15b966ecda7e1145b21b80f8fe8f88" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-prefix-find/cli-tui.tar.gz b/prototypes/connections/results/2026-09-24-prefix-find/cli-tui.tar.gz new file mode 100644 index 0000000..dff004b Binary files /dev/null and b/prototypes/connections/results/2026-09-24-prefix-find/cli-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-prefix-find/package-check.json b/prototypes/connections/results/2026-09-24-prefix-find/package-check.json new file mode 100644 index 0000000..440bc20 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-prefix-find/package-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "54a579102d6f9563ee8d077e49eaf156ad3fc826e42532607fce45a75283b10d", + "archive_bytes": 32233, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-prefix-find/tui-profile.json b/prototypes/connections/results/2026-09-24-prefix-find/tui-profile.json new file mode 100644 index 0000000..fd11cb1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-prefix-find/tui-profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app, including the bounded document browser. The raised text ceiling reviews its measured 2,541-byte addition; default CLI and core ceilings are unchanged. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS, including 1,024-key browsing, is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "4d3097c9ec3c0139cbdd82161a74d610c15d31c6ee5625101410627664f925e7", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 32175, + "data": 1036, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "39b5bcf605739eabd731e471a3f54e03592e2bfc0a405db5faacdb4df84d0b22" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-prefix-find/tui.json b/prototypes/connections/results/2026-09-24-prefix-find/tui.json new file mode 100644 index 0000000..6588e17 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-prefix-find/tui.json @@ -0,0 +1,22 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1824, + "one_mib_peak_rss_kib": 3200, + "one_thousand_key_list_peak_rss_kib": 1904, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "39b5bcf605739eabd731e471a3f54e03592e2bfc0a405db5faacdb4df84d0b22" +} diff --git a/prototypes/connections/results/2026-09-24-release-callback/async-core.json b/prototypes/connections/results/2026-09-24-release-callback/async-core.json new file mode 100644 index 0000000..6e5371e --- /dev/null +++ b/prototypes/connections/results/2026-09-24-release-callback/async-core.json @@ -0,0 +1,55 @@ +{ + "passed": true, + "failures": [], + "budget": { + "profile": "selected-owner-thread-async-effects", + "max_text_bytes": 8192, + "max_data_bytes": 0, + "max_bss_bytes": 0, + "layout_max_bytes": { + "domain_bytes": 128, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "effect_bytes": 128 + }, + "archive_members": [ + "runtime-async.o", + "evaluate.o" + ], + "external_symbols_allowed": [ + "memcpy", + "memmove", + "memset", + "strlen" + ], + "scope": "Selected compact owner-thread completion extension without opt-in PURE_RUNTIME_METRICS. Host I/O and worker stacks are excluded. The default execution core and fixed document packages do not link this profile." + }, + "layout": { + "domain_bytes": 128, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8, + "effect_bytes": 128 + }, + "sections": { + "text": 7372, + "data": 0, + "bss": 0 + }, + "members": [ + "runtime-async.o", + "evaluate.o" + ], + "external_symbols": [ + "memcpy", + "strlen" + ], + "size_output": " text\t data\t bss\t dec\t hex\tfilename\n 6452\t 0\t 0\t 6452\t 1934\truntime-async.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution-async.a)\n 920\t 0\t 0\t 920\t 398\tevaluate.o (ex /home/eunhhu/work/pure/prototypes/connections/build/libpure-execution-async.a)\n", + "archive_sha256": "e6b90a42ca7dc894483c920d59c606895a83d16fe2e6b4f1d0a6d36a83d7efb5", + "layout_binary_sha256": "be9307acf8264bc1b333dc05fa0e4b162f912ce8e8b82e3d20eddf49e269d42c", + "budget_sha256": "33afabc20949bc322af9e86b946d39147cd714e32d4e095bc0cc404d11aa79ad", + "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/connections/results/2026-09-24-release-callback/core.json b/prototypes/connections/results/2026-09-24-release-callback/core.json new file mode 100644 index 0000000..6182c51 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-release-callback/core.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": 128, + "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": "Compact release object sections and C bookkeeping, without opt-in PURE_RUNTIME_METRICS, 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": 128, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8 + }, + "sections": { + "text": 5980, + "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 5060\t 0\t 0\t 5060\t 13c4\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": "c5f05b1fb9d51f244687425a1b1d2f8922f6f899f0b617b4de490c730d106e97", + "layout_binary_sha256": "04c008fb7e0b1ecc8435bce6fbc4478de6180a54da6538d8742acb9b51992fe3", + "budget_sha256": "e418419db6231305342ea8780149948e0e41e28b3afb937268e80b4695d3d5cd", + "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/connections/results/2026-09-24-release-callback/micro-package-check.json b/prototypes/connections/results/2026-09-24-release-callback/micro-package-check.json new file mode 100644 index 0000000..9db433b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-release-callback/micro-package-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "722d68e58d948810c4a181d0dc832ad97bc5c65b5f5629d1af5866aff920e0b5", + "archive_bytes": 22758, + "selected_hosts": [ + "pure-edit" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply" + ] +} diff --git a/prototypes/connections/results/2026-09-24-release-callback/pure-document-micro-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-release-callback/pure-document-micro-linux-aarch64.tar.gz new file mode 100644 index 0000000..44f9c7f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-release-callback/pure-document-micro-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-release-callback/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-release-callback/pure-source-preview.tar.gz new file mode 100644 index 0000000..68e4f0a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-release-callback/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-release-callback/runtime.json b/prototypes/connections/results/2026-09-24-release-callback/runtime.json new file mode 100644 index 0000000..6f6527d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-release-callback/runtime.json @@ -0,0 +1 @@ +{"runtime_tests":"passed","domain_bytes":200,"work_slot_bytes":224,"ticket_bytes":24,"prepared_program_bytes":680,"forwarding_chain":1024,"native_storage_pipeline":true,"idle_slot_binding":true,"release_reentrancy_guard":true} diff --git a/prototypes/connections/results/2026-09-24-release-callback/source-package-check.json b/prototypes/connections/results/2026-09-24-release-callback/source-package-check.json new file mode 100644 index 0000000..459429c --- /dev/null +++ b/prototypes/connections/results/2026-09-24-release-callback/source-package-check.json @@ -0,0 +1,15 @@ +{ + "archive_sha256": "cff657597c0cc33bd9185c8460e0d84f0843da20a786578324ac612fa255c2b4", + "archive_bytes": 333037, + "source_files": 170, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean selected page store exports mixed revisions to the default flat store", + "clean one-MiB document fans out to two same-process Pure domains with independent conflict and release", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-server-only-package/combined-peer-package-check.json b/prototypes/connections/results/2026-09-24-server-only-package/combined-peer-package-check.json new file mode 100644 index 0000000..d8ed889 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-server-only-package/combined-peer-package-check.json @@ -0,0 +1,27 @@ +{ + "archive_sha256": "d1592ec4e4085ebe328c01ccf49911c383fc87908344cfea5fb9bca56a0fef96", + "archive_bytes": 49382, + "selected_hosts": [ + "pure-edit", + "pure-document-peer" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted C/Python TLS peers share a 1 MiB document and recover" + ], + "peer_checks": [ + "mutual certificate identity, server hostname and ALPN separation", + "program source identity checked before C client sends work", + "persistent external TLS client: create, read, conflict and recovery", + "native peer edit and local CLI use the same authored document and storage", + "fragmented 1 MiB direct ingress, demand-selected reply and one-byte splice", + "native C peer uploads 1 MiB and receives only the requested metadata", + "incomplete, invalid and oversized frames do not publish a change", + "idle authenticated session closes after deadline", + "server restart and native C peer preserve the committed large document" + ], + "peer_idle_rss_kib": 7968, + "peer_after_large_work_rss_kib": 9712 +} diff --git a/prototypes/connections/results/2026-09-24-server-only-package/pure-document-combined-peer-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-server-only-package/pure-document-combined-peer-linux-aarch64.tar.gz new file mode 100644 index 0000000..ebd24da Binary files /dev/null and b/prototypes/connections/results/2026-09-24-server-only-package/pure-document-combined-peer-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-server-only-package/pure-document-server-only-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-server-only-package/pure-document-server-only-linux-aarch64.tar.gz new file mode 100644 index 0000000..2a47d7e Binary files /dev/null and b/prototypes/connections/results/2026-09-24-server-only-package/pure-document-server-only-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-server-only-package/server-only-package-check.json b/prototypes/connections/results/2026-09-24-server-only-package/server-only-package-check.json new file mode 100644 index 0000000..997287d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-server-only-package/server-only-package-check.json @@ -0,0 +1,23 @@ +{ + "archive_sha256": "34fb507c160e4aa719cf44fb4f8bbcb422d2c0ee6dc3f206f6a5fe3bc4d089b6", + "archive_bytes": 47686, + "selected_hosts": [ + "pure-edit", + "pure-document-server" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "extracted server-only TLS peer and external client share a 1 MiB document and recover" + ], + "peer_checks": [ + "relocated server rejects invalid client identity, ALPN and hostname", + "external client and extracted CLI share a conditional document", + "fragmented one MiB ingress, selected reply and CLI-visible splice", + "truncated and invalid frames do not publish a change", + "relocated server restart restores the committed one MiB document" + ], + "peer_idle_rss_kib": 7968, + "peer_after_large_work_rss_kib": 8672 +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-profile.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-profile.json new file mode 100644 index 0000000..733fa60 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-runner", + "scope": "Release-linked CLI and HTTP document hosts with one build-time prepared app. The source parser and compiler are separate build tools. These text ceilings apply only to the recorded Linux AArch64/GCC 14 toolchain and exclude libc, heap, stack, and storage files.", + "budget_sha256": "2b7e550992abb1872e52846dec712fab59b78692b776fd7e648d7976299f4b2a", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-fixed": { + "sections": { + "text": 19244, + "data": 920, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8" + }, + "pure-connections-fixed": { + "sections": { + "text": 25790, + "data": 1024, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "fd474f7331ac47d22360f2bfe8ba491ff8ffac448107b41da74a00142ae97958" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-server-regression.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-server-regression.json new file mode 100644 index 0000000..7857bde --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/fixed-server-regression.json @@ -0,0 +1,68 @@ +{ + "profile": "build-time-prepared-http-document-host", + "fixed_source_cli_same_document": "pass", + "conditional_conflict_and_invalid_splice": "pass", + "one_mib_roundtrip_and_native_edit": "pass", + "slow_consumer_budget_and_release": "pass", + "runtime_parser_symbols_absent": "pass", + "fixed_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048584, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 7, + "commits": 2, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 7, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097177, + "server_structure_bytes": 40848 + }, + "source_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 6, + "commits": 1, + "gets": 3, + "conflicts": 1, + "budget_rejections": 0, + "expired": 0, + "releases": 6, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2097162, + "server_structure_bytes": 41528 + }, + "limited_stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 2, + "accepted": 3, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 1, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048586, + "server_structure_bytes": 40848 + }, + "fixed_idle_rss_kib": 1504, + "source_idle_rss_kib": 1552, + "fixed_after_large_rss_kib": 1632, + "source_after_large_rss_kib": 1680, + "fixed_peak_rss_kib": 2112, + "source_peak_rss_kib": 2096, + "fixed_peak_rss_limit_kib": 4096, + "fixed_sha256": "fd474f7331ac47d22360f2bfe8ba491ff8ffac448107b41da74a00142ae97958", + "source_sha256": "9818530bdadd3da2c3cd63feb24f0585f8527887bcc9c8528d67736ee3a97653" +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/package.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/package.json new file mode 100644 index 0000000..8304c17 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "413f88a31272c5a0851ab598673de5523fa12d913e0a077441aa342b506498e2", + "archive_bytes": 32023, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document" + ] +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/pure-document-fixed-http-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-signal-safe-idle/pure-document-fixed-http-linux-aarch64.tar.gz new file mode 100644 index 0000000..54c21a2 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-signal-safe-idle/pure-document-fixed-http-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/reproducible-comparison.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/reproducible-comparison.json new file mode 100644 index 0000000..79f8c49 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/reproducible-comparison.json @@ -0,0 +1,21 @@ +{ + "profile": "idle-http-host-poll-syscalls", + "observation_seconds": 1.25, + "before": { + "poll_calls": 25, + "timeout_values_ms": [ + 50 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, {tv_sec=0, tv_nsec=50000000}, NULL, 0) = 0 (Timeout)\nppoll([{fd=3, events=PO", + "binary_sha256": "7dd0010fa6d6024afa2fe35d57a4c73f33ac632e732616ac8f8853d7b22d79b3" + }, + "after": { + "poll_calls": 1, + "timeout_values_ms": [ + -1 + ], + "trace_excerpt": "ppoll([{fd=3, events=POLLIN}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}, {fd=-1}], 9, NULL, [], 8) = ? ERESTARTNOHAND (To be restarted if no handler)\n--- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=932958, si_uid=1000} ---\n", + "binary_sha256": "fd474f7331ac47d22360f2bfe8ba491ff8ffac448107b41da74a00142ae97958" + }, + "deadline_driven_idle_wait": "pass" +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale-restored.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale-restored.json new file mode 100644 index 0000000..a4a9764 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale-restored.json @@ -0,0 +1,62 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "fd474f7331ac47d22360f2bfe8ba491ff8ffac448107b41da74a00142ae97958", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1520, + "peak_rss_kib": 1696, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49200, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 696751, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "9818530bdadd3da2c3cd63feb24f0585f8527887bcc9c8528d67736ee3a97653", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "fresh_cli_restore_samples": 4, + "idle_rss_kib": 1552, + "peak_rss_kib": 1712, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 40993, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 410074, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale.json new file mode 100644 index 0000000..4bedc63 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/service-scale.json @@ -0,0 +1,60 @@ +{ + "profile": "document-app-many-objects-concurrent-clients", + "documents_per_variant": 1024, + "clients": 8, + "body_bytes": 8192, + "operations_per_variant": 4096, + "server_budget_bytes": 1048576, + "peak_rss_limit_kib": 4096, + "same_final_documents": "pass", + "fixed": { + "binary_sha256": "fd474f7331ac47d22360f2bfe8ba491ff8ffac448107b41da74a00142ae97958", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "idle_rss_kib": 1520, + "peak_rss_kib": 1712, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 49201, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 899986, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 40848 + } + }, + "source": { + "binary_sha256": "9818530bdadd3da2c3cd63feb24f0585f8527887bcc9c8528d67736ee3a97653", + "document_digest": "e54baed00186fb020edbbcd407d86471c136287de346e3386ce52d16883e488b", + "stored_objects": 1024, + "stored_file_bytes": 8413184, + "idle_rss_kib": 1552, + "peak_rss_kib": 1728, + "stats": { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 41010, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 4096, + "commits": 2048, + "gets": 2048, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 4096, + "ingress_prefix_copy_bytes": 939752, + "borrowed_output_bytes": 25166848, + "server_structure_bytes": 41528 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-signal-safe-idle/source-profile.json b/prototypes/connections/results/2026-09-24-signal-safe-idle/source-profile.json new file mode 100644 index 0000000..5bc9266 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-signal-safe-idle/source-profile.json @@ -0,0 +1,32 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-default-document-hosts", + "scope": "Review thresholds for release-built default CLI and HTTP adapter on the recorded Linux AArch64/GCC 14 toolchain. These section sizes exclude shared libc, heap, stack, mapped files, kernel buffers, and optional GTK/TLS hosts. Any failure requires a cost review, not automatic relaxation.", + "budget_sha256": "8b1eb0dd4844757705c24a0674142e76d9ee83fdce80a841e87a08e757f1183d", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit": { + "sections": { + "text": 35807, + "data": 944, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "bce466328ac730ccb7cd22c7a0c4e2960bc94a0f20ac9f036e232bd499521ff7" + }, + "pure-connections": { + "sections": { + "text": 43451, + "data": 1064, + "bss": 16 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "9818530bdadd3da2c3cd63feb24f0585f8527887bcc9c8528d67736ee3a97653" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-source-preview/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-source-preview/pure-source-preview.tar.gz new file mode 100644 index 0000000..45e83a0 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-source-preview/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-source-preview/source-check.json b/prototypes/connections/results/2026-09-24-source-preview/source-check.json new file mode 100644 index 0000000..a3b7681 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-preview/source-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "a11a058fa0ba71878a0fed0ee63100566f580721fa5cb602ad25cd46e6b94f6f", + "archive_bytes": 316748, + "source_files": 161, + "checks": [ + "every extracted source file matches the manifest", + "clean C17 build and host-neutral behavior tests pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean Linux release behavior, code, dependency and RSS gates pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-source-tui/core.json b/prototypes/connections/results/2026-09-24-source-tui/core.json new file mode 100644 index 0000000..63eb227 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/core.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": 128, + "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": "Compact release object sections and C bookkeeping, without opt-in PURE_RUNTIME_METRICS, 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": 128, + "work_slot_bytes": 224, + "ticket_bytes": 24, + "prepared_program_bytes": 680, + "pointer_bytes": 8 + }, + "sections": { + "text": 5908, + "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 4988\t 0\t 0\t 4988\t 137c\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": "bb5750a47f6f8ac4307dd633d836460d6045347880a6c53df9669e675e49929e", + "layout_binary_sha256": "04c008fb7e0b1ecc8435bce6fbc4478de6180a54da6538d8742acb9b51992fe3", + "budget_sha256": "e418419db6231305342ea8780149948e0e41e28b3afb937268e80b4695d3d5cd", + "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/connections/results/2026-09-24-source-tui/fixed-cli-tui-check.json b/prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui-check.json new file mode 100644 index 0000000..49d2f70 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "c0d600fd9b80a5bdeacb4c015d0050ddf3fde6f8a3ff1dd86905e3fcb681fd30", + "archive_bytes": 31929, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui.tar.gz b/prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui.tar.gz new file mode 100644 index 0000000..e576af8 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-source-tui/fixed-cli-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-source-tui/fixed-tui-profile.json b/prototypes/connections/results/2026-09-24-source-tui/fixed-tui-profile.json new file mode 100644 index 0000000..1c5a451 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/fixed-tui-profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app, including the bounded document browser. The raised text ceiling reviews its measured 2,541-byte addition; default CLI and core ceilings are unchanged. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS, including 1,024-key browsing, is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "4d3097c9ec3c0139cbdd82161a74d610c15d31c6ee5625101410627664f925e7", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 31999, + "data": 1036, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "45491c3e28193024d6eb77cbd6569ee8b7470d11ec4126ff4515dba116790308" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-source-tui/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-source-tui/pure-source-preview.tar.gz new file mode 100644 index 0000000..265a111 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-source-tui/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-source-tui/source-check.json b/prototypes/connections/results/2026-09-24-source-tui/source-check.json new file mode 100644 index 0000000..e7569bb --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/source-check.json @@ -0,0 +1,13 @@ +{ + "archive_sha256": "6134ff966d09c76134b921008d16b828a349b7031bdf029379de6eea9e745eaa", + "archive_bytes": 318619, + "source_files": 162, + "checks": [ + "every extracted source file matches the manifest", + "clean C17 build and host-neutral behavior tests pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/results/2026-09-24-source-tui/source-cli-tui-check.json b/prototypes/connections/results/2026-09-24-source-tui/source-cli-tui-check.json new file mode 100644 index 0000000..1c3dd89 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/source-cli-tui-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "6b2671f7e2faf91e96ec9c29d03f5087a1e66f26fbfcc93865700a22e90df9de", + "archive_bytes": 50942, + "selected_hosts": [ + "pure-edit", + "pure-edit-tui" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "extracted native CLI creates and restores the real document", + "editing the packaged .pure source changes the executed reply", + "relocated source-loaded TUI and CLI share UTF-8, large and many-document paths" + ] +} diff --git a/prototypes/connections/results/2026-09-24-source-tui/source-cli-tui.tar.gz b/prototypes/connections/results/2026-09-24-source-tui/source-cli-tui.tar.gz new file mode 100644 index 0000000..0e2e38f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-source-tui/source-cli-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-source-tui/tui-profile.json b/prototypes/connections/results/2026-09-24-source-tui/tui-profile.json new file mode 100644 index 0000000..348807d --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/tui-profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-source-document-terminal-editor", + "scope": "Optional source-loaded POSIX terminal editor for the same document app. Includes the runtime .pure parser and bounded document browser. Default core, fixed CLI and fixed TUI do not link the parser. These are Linux AArch64/GCC 14 section regression ceilings, not whole-process RAM; check_tui.py measures the selected whole process separately.", + "budget_sha256": "52df1c0a75119edbe0cfd75d6961c1713237146d44a26cd3b2ce096e4d80d624", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui": { + "sections": { + "text": 48604, + "data": 1084, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "e4fb890935f77a7b359163758ce4c44f3710d1a99130a7d883a4f7329a20a70d" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-source-tui/tui.json b/prototypes/connections/results/2026-09-24-source-tui/tui.json new file mode 100644 index 0000000..184936b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-source-tui/tui.json @@ -0,0 +1,25 @@ +{ + "profile": "native-source-document-terminal-editor", + "checks": [ + "source parse and incompatible definitions fail before store creation", + "source CLI and TUI read the same document", + "changed source field layout drives TUI input encoding", + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, searched, resized and opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1856, + "one_mib_peak_rss_kib": 3216, + "one_thousand_key_list_peak_rss_kib": 1952, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "e4fb890935f77a7b359163758ce4c44f3710d1a99130a7d883a4f7329a20a70d" +} diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/cli-check.json b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-check.json new file mode 100644 index 0000000..d0bb1fc --- /dev/null +++ b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-check.json @@ -0,0 +1,12 @@ +{ + "archive_sha256": "19573b079d8d28b08aebeb185acd1cdfc66c006f183e955fba156f8d94d3c4c8", + "archive_bytes": 14706, + "selected_hosts": [ + "pure-edit-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits" + ] +} diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui-check.json b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui-check.json new file mode 100644 index 0000000..47d26f1 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "9948083ef0b3b4db1d4efbc4991da1887b2b21a33670c435cd364fcada953f56", + "archive_bytes": 53385, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui.tar.gz b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui.tar.gz new file mode 100644 index 0000000..5d5c967 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-http-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui-check.json b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui-check.json new file mode 100644 index 0000000..6dfc3e8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui-check.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "a497e58e0c8942386f31a3fdbed11f038aa796afb87aff26eeea81871f2465ed", + "archive_bytes": 35642, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui.tar.gz b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui.tar.gz new file mode 100644 index 0000000..ddcd49f Binary files /dev/null and b/prototypes/connections/results/2026-09-24-terminal-document-list/cli-tui.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/cli.tar.gz b/prototypes/connections/results/2026-09-24-terminal-document-list/cli.tar.gz new file mode 100644 index 0000000..7c121af Binary files /dev/null and b/prototypes/connections/results/2026-09-24-terminal-document-list/cli.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/profile.json b/prototypes/connections/results/2026-09-24-terminal-document-list/profile.json new file mode 100644 index 0000000..a46d089 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-terminal-document-list/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app, including the bounded document browser. The raised text ceiling reviews its measured 2,541-byte addition; default CLI and core ceilings are unchanged. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS, including 1,024-key browsing, is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "4d3097c9ec3c0139cbdd82161a74d610c15d31c6ee5625101410627664f925e7", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 31303, + "data": 1036, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "8b831e7582a0872b90c8b8b3dc4d30088393c3a88713a4eae938c8816bdcda68" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/sanitized.json b/prototypes/connections/results/2026-09-24-terminal-document-list/sanitized.json new file mode 100644 index 0000000..d49a34b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-terminal-document-list/sanitized.json @@ -0,0 +1,22 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, resized and last key opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 9856, + "one_mib_peak_rss_kib": 11744, + "one_thousand_key_list_peak_rss_kib": 9904, + "one_mib_peak_rss_limit_kib": 0, + "binary_sha256": "4b4b553289e4cec10aa58e0ef5442905f84d2ddef72fa3d36f301825b2b9ac84" +} diff --git a/prototypes/connections/results/2026-09-24-terminal-document-list/tui.json b/prototypes/connections/results/2026-09-24-terminal-document-list/tui.json new file mode 100644 index 0000000..d8d45f8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-terminal-document-list/tui.json @@ -0,0 +1,22 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "bounded document browser selects keys and protects unsaved draft", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "1,024 real documents listed, resized and last key opened within RSS gate", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1824, + "one_mib_peak_rss_kib": 2832, + "one_thousand_key_list_peak_rss_kib": 1856, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "8b831e7582a0872b90c8b8b3dc4d30088393c3a88713a4eae938c8816bdcda68" +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/latency-baseline.json b/prototypes/connections/results/2026-09-24-tui-viewport/latency-baseline.json new file mode 100644 index 0000000..03c1ff9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/latency-baseline.json @@ -0,0 +1,176 @@ +{ + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "terminal": "PTY 24x80, C.UTF-8", + "tui_sha256": "6a38883fef9fcfaf0b24516d0ca1b46edb595f988a9f756c030a3a84ef215107", + "cli_sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8", + "workloads": { + "256-byte-ascii": { + "document_bytes": 256, + "samples": [ + { + "nanoseconds": 42778, + "frame_bytes": 467, + "frame_sha256": "79ce175afbd5e1168173ac158c7cba370251a226142ec00780e735b7d71de36d" + }, + { + "nanoseconds": 32612, + "frame_bytes": 468, + "frame_sha256": "84aeb3b92beaccfaa8b4b537280122c8aece74222c9ece27a7c2e0a7a5575371" + }, + { + "nanoseconds": 28296, + "frame_bytes": 469, + "frame_sha256": "b04bcefe68401c190b7dab9c3c29bb09cec9f4db6140d285bd2fe09ff0fa5203" + }, + { + "nanoseconds": 28074, + "frame_bytes": 470, + "frame_sha256": "01c628338d94e9cc3371dcdb33d643c6d87d17808e04e388dc0305f2e2bf0a01" + }, + { + "nanoseconds": 27944, + "frame_bytes": 471, + "frame_sha256": "02d4c8ab2f98fe5e99cf17237d3daa75b484472928e4105d241c06c68b082ccc" + }, + { + "nanoseconds": 28612, + "frame_bytes": 472, + "frame_sha256": "825d74dda937507686530ce7acbff1aafcc3c236feb6befa218f2ccbe33d480b" + }, + { + "nanoseconds": 32815, + "frame_bytes": 473, + "frame_sha256": "9458d218e52f093480dccf23b816dac00c5cc5517b4b145820061f98b61d2b32" + }, + { + "nanoseconds": 28093, + "frame_bytes": 474, + "frame_sha256": "02d7602be4f267cdc2db7b853ecfb743dc594c941b9f6a4be9d3262b16d71fda" + }, + { + "nanoseconds": 27556, + "frame_bytes": 475, + "frame_sha256": "21426683adeee1036c8fe02233ab5a8dde1e6f5d2da8f97d67f9b41419c745ca" + }, + { + "nanoseconds": 28297, + "frame_bytes": 476, + "frame_sha256": "d5c581fddffb80146c1b617be580a65f5936c79dc04deeada0c5b90eaa10b193" + } + ], + "median_nanoseconds": 28296 + }, + "one-mib-ascii": { + "document_bytes": 1048576, + "samples": [ + { + "nanoseconds": 15694866, + "frame_bytes": 1950, + "frame_sha256": "1ee95680f91eda6c69e7b4009274591735b5b9d2825ed2076cf372acc70c613f" + }, + { + "nanoseconds": 15542421, + "frame_bytes": 1951, + "frame_sha256": "5121e7e36f445a218af46da6796e3124997a7e25b652225f25d93b60d5e09b40" + }, + { + "nanoseconds": 15586588, + "frame_bytes": 1952, + "frame_sha256": "c7b503e012baefa46d8663b98a59f1d1faf4d2b3831750f736984e6443245075" + }, + { + "nanoseconds": 15921866, + "frame_bytes": 1953, + "frame_sha256": "b7edf856b10b641faf4825a8444f386f3faf1ac1e1ccf9a187db6f1465b5264b" + }, + { + "nanoseconds": 15594088, + "frame_bytes": 1954, + "frame_sha256": "cbaaf4f2990d9c060f48cf4dd6e6fab3d872c2370302960a893bcd4c13fa4c47" + }, + { + "nanoseconds": 15704829, + "frame_bytes": 1955, + "frame_sha256": "95ce7a29f9dd111a0a319455231b79bcc41fdf897e8db3411a24821c6cc298c6" + }, + { + "nanoseconds": 15593014, + "frame_bytes": 1956, + "frame_sha256": "440d25b06f6d7dda71bd02225191f0f430a4f4278a10d3bb21b56788ccbd62c4" + }, + { + "nanoseconds": 15696681, + "frame_bytes": 1957, + "frame_sha256": "5a0f58e4f0e16194202e3f56608a6411488aa517378abedf8ff428644e63c9cb" + }, + { + "nanoseconds": 15719440, + "frame_bytes": 1958, + "frame_sha256": "4f3d92e9f165d680c4546040e0ac141db54176f6576893d1a24e89053b487b03" + }, + { + "nanoseconds": 15660421, + "frame_bytes": 1959, + "frame_sha256": "ee37bf811fd99378f57715a638fd589d5c518b34dfdc76d973c64203c2ef16f8" + } + ], + "median_nanoseconds": 15677643 + }, + "one-mib-utf8": { + "document_bytes": 1048572, + "samples": [ + { + "nanoseconds": 37168940, + "frame_bytes": 2745, + "frame_sha256": "e7275636645244ab12463d96a08bacd5c87aec3307bf86f91399ca654531140a" + }, + { + "nanoseconds": 37198756, + "frame_bytes": 2746, + "frame_sha256": "7d183f69c1dce6fee9b86e6b2c10e01f3779b13753fb1c11160de78505f9a8e0" + }, + { + "nanoseconds": 37112959, + "frame_bytes": 2747, + "frame_sha256": "7d0190b9b944eaa7fcd07af573882df6d436a7847e51dca759a65e93d9100c8e" + }, + { + "nanoseconds": 37318718, + "frame_bytes": 2748, + "frame_sha256": "858d20af32237847e72800093ec28e1998e71c22d0991d75011bf661682323e8" + }, + { + "nanoseconds": 37213200, + "frame_bytes": 2749, + "frame_sha256": "2e848f851e174be238ce270f71749536e473c97ad2f075a55aaf020fc5438c77" + }, + { + "nanoseconds": 37296497, + "frame_bytes": 2750, + "frame_sha256": "77d4bb9a8028ea5420435bdbe23b0c188d6f6065a8eda79c6357defb32605be8" + }, + { + "nanoseconds": 37084125, + "frame_bytes": 2751, + "frame_sha256": "18d8fcba8c9d11f0cf44e2171fb613d49e45f94408b4307526fc90bec5b61e5a" + }, + { + "nanoseconds": 37309700, + "frame_bytes": 2752, + "frame_sha256": "977adff244a908e5b59448e1b8075139fbcc7b35410d0d335ad67580d3d54c44" + }, + { + "nanoseconds": 37275311, + "frame_bytes": 2753, + "frame_sha256": "1e6c90831630b4573e9dec18872d26056e6e96d68e2cfcbe7da8102f0ef8b490" + }, + { + "nanoseconds": 37298940, + "frame_bytes": 2754, + "frame_sha256": "b3599665cc5fb2a72f9a582604c5caaeceff475fa2986f3242adfb4799089600" + } + ], + "median_nanoseconds": 37244255 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/latency-current.json b/prototypes/connections/results/2026-09-24-tui-viewport/latency-current.json new file mode 100644 index 0000000..92f6cd0 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/latency-current.json @@ -0,0 +1,176 @@ +{ + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "terminal": "PTY 24x80, C.UTF-8", + "tui_sha256": "4373eab2cdb033ce54b812232c7787a3a572de26e303daa9dd5001489638c899", + "cli_sha256": "8b594a7b1a4ae5b33cde5c842ec1e11c79b6368babc08ac2ffea378b38ac24f8", + "workloads": { + "256-byte-ascii": { + "document_bytes": 256, + "samples": [ + { + "nanoseconds": 59019, + "frame_bytes": 467, + "frame_sha256": "79ce175afbd5e1168173ac158c7cba370251a226142ec00780e735b7d71de36d" + }, + { + "nanoseconds": 39463, + "frame_bytes": 468, + "frame_sha256": "84aeb3b92beaccfaa8b4b537280122c8aece74222c9ece27a7c2e0a7a5575371" + }, + { + "nanoseconds": 32555, + "frame_bytes": 469, + "frame_sha256": "b04bcefe68401c190b7dab9c3c29bb09cec9f4db6140d285bd2fe09ff0fa5203" + }, + { + "nanoseconds": 30074, + "frame_bytes": 470, + "frame_sha256": "01c628338d94e9cc3371dcdb33d643c6d87d17808e04e388dc0305f2e2bf0a01" + }, + { + "nanoseconds": 26759, + "frame_bytes": 471, + "frame_sha256": "02d4c8ab2f98fe5e99cf17237d3daa75b484472928e4105d241c06c68b082ccc" + }, + { + "nanoseconds": 27667, + "frame_bytes": 472, + "frame_sha256": "825d74dda937507686530ce7acbff1aafcc3c236feb6befa218f2ccbe33d480b" + }, + { + "nanoseconds": 27371, + "frame_bytes": 473, + "frame_sha256": "9458d218e52f093480dccf23b816dac00c5cc5517b4b145820061f98b61d2b32" + }, + { + "nanoseconds": 29278, + "frame_bytes": 474, + "frame_sha256": "02d7602be4f267cdc2db7b853ecfb743dc594c941b9f6a4be9d3262b16d71fda" + }, + { + "nanoseconds": 28167, + "frame_bytes": 475, + "frame_sha256": "21426683adeee1036c8fe02233ab5a8dde1e6f5d2da8f97d67f9b41419c745ca" + }, + { + "nanoseconds": 37815, + "frame_bytes": 476, + "frame_sha256": "d5c581fddffb80146c1b617be580a65f5936c79dc04deeada0c5b90eaa10b193" + } + ], + "median_nanoseconds": 29676 + }, + "one-mib-ascii": { + "document_bytes": 1048576, + "samples": [ + { + "nanoseconds": 111481, + "frame_bytes": 1950, + "frame_sha256": "1ee95680f91eda6c69e7b4009274591735b5b9d2825ed2076cf372acc70c613f" + }, + { + "nanoseconds": 73685, + "frame_bytes": 1951, + "frame_sha256": "5121e7e36f445a218af46da6796e3124997a7e25b652225f25d93b60d5e09b40" + }, + { + "nanoseconds": 71222, + "frame_bytes": 1952, + "frame_sha256": "c7b503e012baefa46d8663b98a59f1d1faf4d2b3831750f736984e6443245075" + }, + { + "nanoseconds": 69871, + "frame_bytes": 1953, + "frame_sha256": "b7edf856b10b641faf4825a8444f386f3faf1ac1e1ccf9a187db6f1465b5264b" + }, + { + "nanoseconds": 69945, + "frame_bytes": 1954, + "frame_sha256": "cbaaf4f2990d9c060f48cf4dd6e6fab3d872c2370302960a893bcd4c13fa4c47" + }, + { + "nanoseconds": 69833, + "frame_bytes": 1955, + "frame_sha256": "95ce7a29f9dd111a0a319455231b79bcc41fdf897e8db3411a24821c6cc298c6" + }, + { + "nanoseconds": 70259, + "frame_bytes": 1956, + "frame_sha256": "440d25b06f6d7dda71bd02225191f0f430a4f4278a10d3bb21b56788ccbd62c4" + }, + { + "nanoseconds": 69537, + "frame_bytes": 1957, + "frame_sha256": "5a0f58e4f0e16194202e3f56608a6411488aa517378abedf8ff428644e63c9cb" + }, + { + "nanoseconds": 69648, + "frame_bytes": 1958, + "frame_sha256": "4f3d92e9f165d680c4546040e0ac141db54176f6576893d1a24e89053b487b03" + }, + { + "nanoseconds": 70092, + "frame_bytes": 1959, + "frame_sha256": "ee37bf811fd99378f57715a638fd589d5c518b34dfdc76d973c64203c2ef16f8" + } + ], + "median_nanoseconds": 70018 + }, + "one-mib-utf8": { + "document_bytes": 1048572, + "samples": [ + { + "nanoseconds": 239129, + "frame_bytes": 2745, + "frame_sha256": "e7275636645244ab12463d96a08bacd5c87aec3307bf86f91399ca654531140a" + }, + { + "nanoseconds": 228593, + "frame_bytes": 2746, + "frame_sha256": "7d183f69c1dce6fee9b86e6b2c10e01f3779b13753fb1c11160de78505f9a8e0" + }, + { + "nanoseconds": 227148, + "frame_bytes": 2747, + "frame_sha256": "7d0190b9b944eaa7fcd07af573882df6d436a7847e51dca759a65e93d9100c8e" + }, + { + "nanoseconds": 227500, + "frame_bytes": 2748, + "frame_sha256": "858d20af32237847e72800093ec28e1998e71c22d0991d75011bf661682323e8" + }, + { + "nanoseconds": 237037, + "frame_bytes": 2749, + "frame_sha256": "2e848f851e174be238ce270f71749536e473c97ad2f075a55aaf020fc5438c77" + }, + { + "nanoseconds": 228370, + "frame_bytes": 2750, + "frame_sha256": "77d4bb9a8028ea5420435bdbe23b0c188d6f6065a8eda79c6357defb32605be8" + }, + { + "nanoseconds": 226463, + "frame_bytes": 2751, + "frame_sha256": "18d8fcba8c9d11f0cf44e2171fb613d49e45f94408b4307526fc90bec5b61e5a" + }, + { + "nanoseconds": 261797, + "frame_bytes": 2752, + "frame_sha256": "977adff244a908e5b59448e1b8075139fbcc7b35410d0d335ad67580d3d54c44" + }, + { + "nanoseconds": 272018, + "frame_bytes": 2753, + "frame_sha256": "1e6c90831630b4573e9dec18872d26056e6e96d68e2cfcbe7da8102f0ef8b490" + }, + { + "nanoseconds": 235852, + "frame_bytes": 2754, + "frame_sha256": "b3599665cc5fb2a72f9a582604c5caaeceff475fa2986f3242adfb4799089600" + } + ], + "median_nanoseconds": 232222 + } + } +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/package-http-tui.json b/prototypes/connections/results/2026-09-24-tui-viewport/package-http-tui.json new file mode 100644 index 0000000..2730260 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/package-http-tui.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "270edeb0f897b3ea1eeacc4e89bcff2c45fefbbe77fd1546307298d801a2ed9b", + "archive_bytes": 51844, + "selected_hosts": [ + "pure-edit-fixed", + "pure-connections-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated parser-free HTTP host and CLI share the document", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/package.json b/prototypes/connections/results/2026-09-24-tui-viewport/package.json new file mode 100644 index 0000000..c16b305 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/package.json @@ -0,0 +1,14 @@ +{ + "archive_sha256": "aa29b551905788ec436628fac1cca119fbf783d2c620ead866fd2e842429fb12", + "archive_bytes": 34216, + "selected_hosts": [ + "pure-edit-fixed", + "pure-edit-tui-fixed" + ], + "checks": [ + "relocated archive has only declared files and every file hash matches", + "relocated parser-free runner creates and restores a document", + "relocated parser-free runner performs revision-checked edits", + "relocated terminal editor saves UTF-8 and one MiB documents" + ] +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/profile.json b/prototypes/connections/results/2026-09-24-tui-viewport/profile.json new file mode 100644 index 0000000..84e6783 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/profile.json @@ -0,0 +1,21 @@ +{ + "passed": true, + "failures": [], + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS is checked by check_tui.py on the recorded Linux AArch64 host.", + "budget_sha256": "e378cb302b2a4bcd2ee674af048116f139b214b59f24244d6c825bc71a8e8a31", + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "binaries": { + "pure-edit-tui-fixed": { + "sections": { + "text": 28762, + "data": 988, + "bss": 24 + }, + "needed": [ + "libc.so.6" + ], + "sha256": "4373eab2cdb033ce54b812232c7787a3a572de26e303daa9dd5001489638c899" + } + } +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/pty-flow.json b/prototypes/connections/results/2026-09-24-tui-viewport/pty-flow.json new file mode 100644 index 0000000..a8b63d4 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/pty-flow.json @@ -0,0 +1,19 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 1792, + "one_mib_peak_rss_kib": 3168, + "one_mib_peak_rss_limit_kib": 4096, + "binary_sha256": "4373eab2cdb033ce54b812232c7787a3a572de26e303daa9dd5001489638c899" +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/pty-sanitized.json b/prototypes/connections/results/2026-09-24-tui-viewport/pty-sanitized.json new file mode 100644 index 0000000..218e7d8 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-tui-viewport/pty-sanitized.json @@ -0,0 +1,19 @@ +{ + "profile": "native-fixed-document-terminal-editor", + "checks": [ + "cursor insertion and save through terminal", + "open another key", + "UTF-8 codepoint movement, visible redraw and edit", + "resize redraw", + "external revision conflict preserves draft", + "stored multiline and tabbed document survives edit and reopen", + "cached viewport preserves vertical movement across many rows", + "one MiB document viewport, edit and fresh CLI restore", + "one MiB UTF-8 viewport and edit before cached anchor", + "terminal mode restored after key and signal exit" + ], + "small_peak_rss_kib": 9696, + "one_mib_peak_rss_kib": 11680, + "one_mib_peak_rss_limit_kib": 0, + "binary_sha256": "0f1e1a886d3c1617c8aa73e3348892681bae0c1c1428e4b89eac5985410cc988" +} diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-http-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-http-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..3cc8f4a Binary files /dev/null and b/prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-http-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..d8e21cf Binary files /dev/null and b/prototypes/connections/results/2026-09-24-tui-viewport/pure-document-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/final-native-window.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/final-native-window.json new file mode 100644 index 0000000..9b952aa --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/final-native-window.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 15600, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 16880, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 18720, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 20576, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 22672, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 24528, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 25568, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [47216, 48432, 49712, 51552, 53536, 55632, 57488, 58528], "large_window_rss_kib": 44000, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/line-width-4096.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/line-width-4096.json new file mode 100644 index 0000000..f595f21 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/line-width-4096.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_line_width": 4096, "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31344, "heap": 13088, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31344, "heap": 15984, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31344, "heap": 17936, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31344, "heap": 20544, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31472, "heap": 23440, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31472, "heap": 25392, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31472, "heap": 28000, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31472, "heap": 30880, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [46000, 48896, 50848, 53456, 56480, 58432, 61040, 63920], "large_window_rss_kib": 42736, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/mallinfo.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/mallinfo.json new file mode 100644 index 0000000..9c77082 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/mallinfo.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_malloc_samples_bytes": [{"arena": 5849088, "free": 1371840, "key": "large", "mapped": 1392640, "used": 4477248}, {"arena": 14450688, "free": 5148272, "key": "large_other", "mapped": 606208, "used": 9302416}, {"arena": 16990208, "free": 7444160, "key": "large", "mapped": 606208, "used": 9546048}, {"arena": 16990208, "free": 7217216, "key": "large_other", "mapped": 606208, "used": 9772992}, {"arena": 16990208, "free": 6992480, "key": "large", "mapped": 606208, "used": 9997728}, {"arena": 16990208, "free": 6767584, "key": "large_other", "mapped": 606208, "used": 10222624}, {"arena": 16990208, "free": 6543008, "key": "large", "mapped": 606208, "used": 10447200}, {"arena": 16990208, "free": 6317776, "key": "large_other", "mapped": 606208, "used": 10672432}, {"arena": 16990208, "free": 6092896, "key": "large", "mapped": 606208, "used": 10897312}, {"arena": 16990208, "free": 5867616, "key": "large_other", "mapped": 606208, "used": 11122592}, {"arena": 16990208, "free": 5643472, "key": "large", "mapped": 606208, "used": 11346736}, {"arena": 16990208, "free": 5417776, "key": "large_other", "mapped": 606208, "used": 11572432}, {"arena": 17711104, "free": 5915184, "key": "large", "mapped": 606208, "used": 11795920}, {"arena": 17711104, "free": 5689968, "key": "large_other", "mapped": 606208, "used": 12021136}, {"arena": 17711104, "free": 5465984, "key": "large", "mapped": 606208, "used": 12245120}, {"arena": 17711104, "free": 5236704, "key": "large_other", "mapped": 606208, "used": 12474400}, {"arena": 17711104, "free": 5013232, "key": "large", "mapped": 606208, "used": 12697872}, {"arena": 18759680, "free": 5836384, "key": "large_other", "mapped": 606208, "used": 12923296}, {"arena": 18759680, "free": 5613104, "key": "large", "mapped": 606208, "used": 13146576}, {"arena": 18759680, "free": 5387408, "key": "large_other", "mapped": 606208, "used": 13372272}, {"arena": 18759680, "free": 5163520, "key": "large", "mapped": 606208, "used": 13596160}, {"arena": 19611648, "free": 5790528, "key": "large_other", "mapped": 606208, "used": 13821120}, {"arena": 19611648, "free": 5567040, "key": "large", "mapped": 606208, "used": 14044608}, {"arena": 19611648, "free": 5341600, "key": "large_other", "mapped": 606208, "used": 14270048}, {"arena": 19611648, "free": 5118352, "key": "large", "mapped": 606208, "used": 14493296}, {"arena": 20660224, "free": 5941504, "key": "large_other", "mapped": 606208, "used": 14718720}, {"arena": 20660224, "free": 5717552, "key": "large", "mapped": 606208, "used": 14942672}, {"arena": 20660224, "free": 5492400, "key": "large_other", "mapped": 606208, "used": 15167824}, {"arena": 20660224, "free": 5268896, "key": "large", "mapped": 606208, "used": 15391328}, {"arena": 20660224, "free": 5043504, "key": "large_other", "mapped": 606208, "used": 15616720}, {"arena": 21708800, "free": 5868816, "key": "large", "mapped": 606208, "used": 15839984}, {"arena": 21708800, "free": 5643168, "key": "large_other", "mapped": 606208, "used": 16065632}, {"arena": 21708800, "free": 5419264, "key": "large", "mapped": 606208, "used": 16289536}, {"arena": 21708800, "free": 5194128, "key": "large_other", "mapped": 606208, "used": 16514672}, {"arena": 21708800, "free": 4969600, "key": "large", "mapped": 606208, "used": 16739200}, {"arena": 22806528, "free": 5842912, "key": "large_other", "mapped": 606208, "used": 16963616}, {"arena": 22806528, "free": 5619648, "key": "large", "mapped": 606208, "used": 17186880}, {"arena": 22806528, "free": 5441248, "key": "large_other", "mapped": 606208, "used": 17365280}, {"arena": 22806528, "free": 5217472, "key": "large", "mapped": 606208, "used": 17589056}, {"arena": 23658496, "free": 5843872, "key": "large_other", "mapped": 606208, "used": 17814624}, {"arena": 23658496, "free": 5620592, "key": "large", "mapped": 606208, "used": 18037904}, {"arena": 23658496, "free": 5395136, "key": "large_other", "mapped": 606208, "used": 18263360}, {"arena": 23658496, "free": 5171792, "key": "large", "mapped": 606208, "used": 18486704}, {"arena": 24707072, "free": 5994912, "key": "large_other", "mapped": 606208, "used": 18712160}, {"arena": 24707072, "free": 5770480, "key": "large", "mapped": 606208, "used": 18936592}, {"arena": 24707072, "free": 5545520, "key": "large_other", "mapped": 606208, "used": 19161552}, {"arena": 24707072, "free": 5321792, "key": "large", "mapped": 606208, "used": 19385280}, {"arena": 24707072, "free": 5096368, "key": "large_other", "mapped": 606208, "used": 19610704}, {"arena": 25755648, "free": 5921760, "key": "large", "mapped": 606208, "used": 19833888}, {"arena": 25755648, "free": 5696016, "key": "large_other", "mapped": 606208, "used": 20059632}, {"arena": 25755648, "free": 5471760, "key": "large", "mapped": 606208, "used": 20283888}, {"arena": 25755648, "free": 5247072, "key": "large_other", "mapped": 606208, "used": 20508576}, {"arena": 25755648, "free": 5022720, "key": "large", "mapped": 606208, "used": 20732928}, {"arena": 26853376, "free": 5895392, "key": "large_other", "mapped": 606208, "used": 20957984}, {"arena": 26853376, "free": 5671920, "key": "large", "mapped": 606208, "used": 21181456}, {"arena": 26853376, "free": 5446704, "key": "large_other", "mapped": 606208, "used": 21406672}, {"arena": 26853376, "free": 5222576, "key": "large", "mapped": 606208, "used": 21630800}, {"arena": 27705344, "free": 5849056, "key": "large_other", "mapped": 606208, "used": 21856288}, {"arena": 27705344, "free": 5625392, "key": "large", "mapped": 606208, "used": 22079952}, {"arena": 27705344, "free": 5400048, "key": "large_other", "mapped": 606208, "used": 22305296}, {"arena": 27705344, "free": 5176640, "key": "large", "mapped": 606208, "used": 22528704}, {"arena": 28753920, "free": 5999456, "key": "large_other", "mapped": 606208, "used": 22754464}, {"arena": 28753920, "free": 5775264, "key": "large", "mapped": 606208, "used": 22978656}, {"arena": 28753920, "free": 5550336, "key": "large_other", "mapped": 606208, "used": 23203584}, {"arena": 28753920, "free": 5326416, "key": "large", "mapped": 606208, "used": 23427504}], "large_switch_map_samples_kib": [{"anonymous": 2352, "file": 31280, "heap": 14464, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31280, "heap": 14928, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31280, "heap": 16784, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31280, "heap": 18832, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31408, "heap": 20736, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31408, "heap": 22016, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31408, "heap": 23856, "other": 16, "stack": 112}, {"anonymous": 2352, "file": 31408, "heap": 25712, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [48224, 48688, 50544, 52592, 54624, 55904, 57744, 59600], "large_window_rss_kib": 44800, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/no-document-undo.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/no-document-undo.json new file mode 100644 index 0000000..7b8cf24 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/no-document-undo.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31280, "heap": 14576, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31280, "heap": 15024, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31280, "heap": 17152, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31280, "heap": 19008, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31408, "heap": 20240, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31408, "heap": 22144, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31408, "heap": 23984, "other": 16, "stack": 128}, {"anonymous": 1440, "file": 31408, "heap": 26016, "other": 16, "stack": 128}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [47440, 47888, 50016, 51872, 53232, 55136, 56976, 59008], "large_window_rss_kib": 44048, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/no-key-undo.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/no-key-undo.json new file mode 100644 index 0000000..6009187 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/no-key-undo.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1456, "file": 31280, "heap": 15600, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 16784, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 18608, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 20320, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 21488, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 23584, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 26800, "other": 16, "stack": 112}, {"anonymous": 1456, "file": 31280, "heap": 27344, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [48464, 49648, 51472, 53184, 54352, 56448, 59664, 60208], "large_window_rss_kib": 44048, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/no-wrap.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/no-wrap.json new file mode 100644 index 0000000..b51a374 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/no-wrap.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31264, "heap": 13056, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 13904, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 15184, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 17024, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 19168, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 20448, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 22288, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31392, "heap": 24480, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [45888, 46736, 48016, 49856, 52128, 53408, 55248, 57440], "large_window_rss_kib": 44000, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/replace-view.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/replace-view.json new file mode 100644 index 0000000..409e1d9 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/replace-view.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_line_width": 80, "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31280, "heap": 14416, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 15632, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 17696, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 19280, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 21248, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 22608, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 24624, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 25968, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [47264, 48480, 50544, 52128, 54096, 55456, 57472, 58816], "large_window_rss_kib": 44032, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/same-body.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/same-body.json new file mode 100644 index 0000000..c8b8149 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/same-body.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31280, "heap": 14368, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 16416, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 18512, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 19696, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 21344, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 23104, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 25280, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 26832, "other": 16, "stack": 112}], "large_switch_mode": "same-body", "large_switch_rss_samples_kib": [47216, 49264, 51360, 52544, 54192, 55952, 58128, 59680], "large_window_rss_kib": 44016, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/same-key.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/same-key.json new file mode 100644 index 0000000..fac529b --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/same-key.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31264, "heap": 14224, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14224, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14224, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14224, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14224, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14240, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14240, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14240, "other": 16, "stack": 112}], "large_switch_mode": "same", "large_switch_rss_samples_kib": [47056, 47056, 47056, 47056, 47056, 47072, 47072, 47072], "large_window_rss_kib": 44016, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/settled-final.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/settled-final.json new file mode 100644 index 0000000..bdbdd78 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/settled-final.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31328, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 16336, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 17600, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 19776, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 21872, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 23056, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 25184, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31328, "heap": 26432, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [47280, 49232, 50496, 52672, 54768, 55952, 58080, 59328], "large_switch_settled_map_kib": {"anonymous": 1440, "file": 31456, "heap": 26432, "other": 16, "stack": 112}, "large_switch_settled_rss_kib": 59456, "large_window_rss_kib": 44064, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/skip-text.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/skip-text.json new file mode 100644 index 0000000..86c3fe7 --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/skip-text.json @@ -0,0 +1 @@ +{"cli_conflict_preserves_draft": "pass", "definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31264, "heap": 14384, "other": 16, "stack": 112}], "large_switch_mode": "alternate", "large_switch_rss_samples_kib": [47216, 47216, 47216, 47216, 47216, 47216, 47216, 47216], "large_window_rss_kib": 44000, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-window-memory-analysis/smaps.json b/prototypes/connections/results/2026-09-24-window-memory-analysis/smaps.json new file mode 100644 index 0000000..1b88acf --- /dev/null +++ b/prototypes/connections/results/2026-09-24-window-memory-analysis/smaps.json @@ -0,0 +1 @@ +{"definition_restores_in_new_window": "pass", "empty_document_restore": "pass", "gtk_cli_write": "pass", "http_conflict_preserves_draft": "pass", "invalid_and_external_definition_preserve_draft": "pass", "large_switch_map_samples_kib": [{"anonymous": 1440, "file": 31280, "heap": 14624, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 15328, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 17184, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31280, "heap": 19040, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31408, "heap": 21136, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31408, "heap": 22992, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31408, "heap": 24032, "other": 16, "stack": 112}, {"anonymous": 1440, "file": 31408, "heap": 25920, "other": 16, "stack": 112}], "large_switch_rss_samples_kib": [47472, 48176, 50032, 51888, 54112, 55968, 57008, 58896], "large_window_rss_kib": 44032, "multi_document_switch_preserves_draft": "pass", "native_definition_apply_and_running_output": "pass", "one_mib_document_one_byte_edit": "pass", "partial_read_definition_rejected": "pass", "profile": "optional-native-document-window", "reloaded_definition_installs_without_rewriting_source": "pass", "source_write_failure_preserves_active_definition": "pass", "unchanged_save_keeps_revision": "pass", "unsaved_close_guard": "pass", "utf8_boundary_and_restart": "pass"} diff --git a/prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..b2f35c3 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..298e8c2 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-work-slot/pure-document-fixed-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-linux-aarch64.tar.gz new file mode 100644 index 0000000..57a22b7 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-tui-linux-aarch64.tar.gz b/prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-tui-linux-aarch64.tar.gz new file mode 100644 index 0000000..38ec704 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-work-slot/pure-document-source-cli-tui-linux-aarch64.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-work-slot/pure-source-preview.tar.gz b/prototypes/connections/results/2026-09-24-work-slot/pure-source-preview.tar.gz new file mode 100644 index 0000000..458b215 Binary files /dev/null and b/prototypes/connections/results/2026-09-24-work-slot/pure-source-preview.tar.gz differ diff --git a/prototypes/connections/results/2026-09-24-work-slot/source-package-check.json b/prototypes/connections/results/2026-09-24-work-slot/source-package-check.json new file mode 100644 index 0000000..708e0fa --- /dev/null +++ b/prototypes/connections/results/2026-09-24-work-slot/source-package-check.json @@ -0,0 +1,16 @@ +{ + "archive_sha256": "a1ba85942463f765c1f908f19419969423f3e474e608dc023e8a3d09b61de50f", + "archive_bytes": 334267, + "source_files": 171, + "checks": [ + "every extracted source file matches the manifest", + "clean C17, both real terminal PTY scenarios and fixed/source CLI document RSS checks pass", + "source-loaded CLI and parser-free CLI share the real stored document", + "editing .pure changes the native source-loaded reply", + "clean selected page store exports mixed revisions to the default flat store", + "clean one-MiB document fans out to two same-process Pure domains with independent conflict and release", + "clean selected page store runs the source-loaded TUI and relocatable CLI+TUI package", + "clean Linux release behavior, code, dependency and RSS gates pass", + "clean source-loaded terminal editor PTY scenarios pass" + ] +} diff --git a/prototypes/connections/runtime.c b/prototypes/connections/runtime.c new file mode 100644 index 0000000..0bfb7bf --- /dev/null +++ b/prototypes/connections/runtime.c @@ -0,0 +1,393 @@ +#include "runtime.h" +#include + +#define NONE SIZE_MAX +enum { FREE, RECEIVING, READY, RUNNING, DONE }; +#ifdef PURE_RUNTIME_METRICS +#define METRIC(...) do { __VA_ARGS__; } while (0) +#else +#define METRIC(...) ((void)0) +#endif + +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; +} + +/* WRITE results are loans from the admitted input, not a second allocation. + * Integer offsets avoid relational comparisons between unrelated C pointers. */ +static bool borrowed_reply(PureSpan input, PureSpan reply) { + if (!reply.size) return true; + if (!input.data || !reply.data) return false; + uintptr_t base = (uintptr_t)input.data, start = (uintptr_t)reply.data; + return start >= base && start - base <= input.size && + reply.size <= input.size - (start - base); +} + +static void dispose(PureRuntime *r, PureResource *resource) { + PureResource old = *resource; + *resource = (PureResource){0}; + if (old.release) { + bool running = r->running; + r->running = true; + old.release(old.allocation, old.allocation_size); + r->running = running; + } +} + +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; +#ifdef PURE_RUNTIME_METRICS + bool released_payload = w->charged || w->resource.release || parent.runtime; +#endif + dispose(r, &w->resource); + r->retained -= w->charged; + METRIC(if (released_payload) ++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; +#ifdef PURE_RUNTIME_METRICS + bool released_payload = w->charged || w->resource.release || parent.runtime; +#endif + dispose(r, &w->resource); + r->retained -= w->charged; + METRIC(if (released_payload) ++r->releases); + w->charged = 0; + w->parent = (PureTicket){0}; + 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 void *definition, PureHost host, size_t budget) { + if (!r || ((work == NULL) != (capacity == 0))) return false; + *r = (PureRuntime){.definition = definition, .host = host, .free_head = NONE, + .ready_head = NONE, .ready_tail = NONE, + .budget = budget, .accepting = true}; + return pure_runtime_bind(r, work, capacity); +} + +bool pure_runtime_bind(PureRuntime *r, PureWork *work, size_t capacity) { + if (!r || r->running || r->active || r->retained || + r->ready_head != NONE || r->ready_tail != NONE || + ((work == NULL) != (capacity == 0))) + return false; + r->work = work; + r->capacity = capacity; + r->free_head = capacity ? 0 : NONE; + 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 void *definition, PureHost host, size_t budget) { + if (!r || r->running || r->active) return false; + r->definition = definition; + 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; + bool missing_effect = input == PURE_INPUT_READ ? !r->host.read : !r->host.write; +#ifdef PURE_ASYNC_EFFECTS + if (missing_effect && !r->async_effects) return PURE_DENIED; +#else + if (missing_effect) return PURE_DENIED; +#endif + if (r->free_head == NONE) return PURE_BUSY; + if (r->serial == UINT64_MAX) return PURE_LIMIT; + if (bytes > r->budget - r->retained) { METRIC(++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; + METRIC(++r->accepted); + r->retained += bytes; + METRIC(if (r->active > r->peak_active) r->peak_active = r->active); + METRIC(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(r, &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; +#ifdef PURE_ASYNC_EFFECTS + PureWork *head = &r->work[r->ready_head]; + if (head->input == PURE_INPUT_READ ? !r->host.read : !r->host.write) return false; +#endif + 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 reply = {0}; + outcome.status = r->host.write(r->host.context, r->definition, w->key, + w->expected, w->resource.body, &reply, + &outcome.revision); + if (outcome.status == PURE_OK) { + if (borrowed_reply(w->resource.body, reply)) outcome.body = reply; + else outcome.status = PURE_STORE_ERROR; + } + METRIC(if (outcome.status == PURE_OK) ++r->commits; + else if (outcome.status == PURE_CONFLICT) ++r->conflicts); + } else { + outcome.status = r->host.read(r->host.context, r->definition, w->key, + 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; + METRIC(if (r->retained > r->peak_retained) r->peak_retained = r->retained); + outcome.body = w->resource.body; + METRIC(++r->gets); + } + } + METRIC(if (outcome.status == PURE_BUDGET) ++r->budget_rejections); + } + if (outcome.status != PURE_OK || !outcome.body.size) { + outcome.body = (PureSpan){0}; + payload_release(r, w); + } + METRIC(r->borrowed_output_bytes += outcome.body.size); + w->outcome = outcome; + w->state = DONE; + r->running = false; + return true; +} + +#ifdef PURE_ASYNC_EFFECTS +bool pure_runtime_enable_async(PureRuntime *r) { + if (!r || r->running || r->active) return false; + r->async_effects = true; + return true; +} + +PureTake pure_runtime_take(PureRuntime *r, PureEffect *out) { + if (!r || !out || !r->async_effects || r->running || r->ready_head == NONE) + return PURE_TAKE_EMPTY; + 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; + *out = (PureEffect){.ticket = {r, slot, w->generation}, .input = w->input, + .key = w->key, .expected = w->expected, + .value = w->resource.body, + .available = r->budget - r->retained}; + return PURE_TAKE_EFFECT; +} + +bool pure_runtime_complete(PureTicket ticket, PureResult status, + PureResource *read_resource, PureSpan write_reply, + int64_t revision) { + PureWork *w = lookup(ticket, false); + PureRuntime *r = ticket.runtime; + if (!w || r->running || w->state != RUNNING || + (w->input == PURE_INPUT_WRITE && read_resource) || + (w->input == PURE_INPUT_READ && write_reply.size) || + (w->input == PURE_INPUT_WRITE && status == PURE_OK && + !borrowed_reply(w->resource.body, write_reply)) || + (read_resource && !read_resource->release && + (read_resource->body.size || read_resource->allocation))) return false; + r->running = true; + PureOutcome outcome = {.status = status, .revision = revision}; + if (w->input == PURE_INPUT_READ) { + PureResource acquired = {0}; + if (read_resource) { + acquired = *read_resource; + *read_resource = (PureResource){0}; + } + if (outcome.status == PURE_OK) { + if (acquired.body.size > r->budget - r->retained) + outcome.status = PURE_BUDGET; + else if (acquired.body.size && (!acquired.body.data || !acquired.release)) + outcome.status = PURE_STORE_ERROR; + else { + w->resource = acquired; + acquired = (PureResource){0}; + w->charged = w->resource.body.size; + r->retained += w->charged; + METRIC(if (r->retained > r->peak_retained) r->peak_retained = r->retained); + outcome.body = w->resource.body; + METRIC(++r->gets); + } + } + dispose(r, &acquired); + METRIC(if (outcome.status == PURE_BUDGET) ++r->budget_rejections); + } else { + if (status == PURE_OK) outcome.body = write_reply; + METRIC(if (outcome.status == PURE_OK) ++r->commits; + else if (outcome.status == PURE_CONFLICT) ++r->conflicts); + } + if (outcome.status != PURE_OK || !outcome.body.size) { + outcome.body = (PureSpan){0}; + payload_release(r, w); + } + METRIC(r->borrowed_output_bytes += outcome.body.size); + w->outcome = outcome; + w->state = DONE; + r->running = false; + if (!w->held) retire(r, w); + return true; +} +#endif + +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; +#ifdef PURE_ASYNC_EFFECTS + if (w->state == RUNNING) return true; +#endif + retire(ticket.runtime, w); + return true; +} diff --git a/prototypes/connections/runtime.h b/prototypes/connections/runtime.h new file mode 100644 index 0000000..0add777 --- /dev/null +++ b/prototypes/connections/runtime.h @@ -0,0 +1,147 @@ +#ifndef PURE_RUNTIME_H +#define PURE_RUNTIME_H +#include "flow.h" +#include "result.h" +#include + +/* The optional metrics profile changes PureRuntime's layout. Give its init + * function a distinct link name so mixed-profile objects cannot link quietly. */ +#ifdef PURE_RUNTIME_METRICS +#define pure_runtime_init pure_runtime_init_metrics +#endif + +/* One owner thread controls admission, payload credits, completion and loans. + * An application definition is opaque to this core. Hosts interpret it and + * perform effects; no parser, storage, socket or OS type is linked here. */ +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. It runs + * while that domain is busy; calls back into the same domain are rejected. */ +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 void *definition, const char *, + size_t available, PureResource *, int64_t *revision); + /* A successful reply may borrow only from input. The runtime retains its + * backing until all reply consumers finish. Effect errors publish no reply. */ + PureResult (*write)(void *, const void *definition, const char *, + int64_t expected, PureSpan input, PureSpan *reply, + 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. */ +/* Internal storage order packs the fixed key around small flags. This is not + * a public ABI; the layout is checked as part of the core cost gate. */ +struct PureWork { + PureTicket parent; + unsigned state; + PureInput input; + bool held; + char key[65]; + int64_t expected; + PureOutcome outcome; + PureResource resource; + uint64_t generation; + size_t next, charged, pins; +}; +struct PureRuntime { + const void *definition; + PureHost host; + PureWork *work; + size_t capacity, free_head, ready_head, ready_tail; + size_t budget, retained; +#ifdef PURE_RUNTIME_METRICS + size_t peak_retained; +#endif + size_t active; +#ifdef PURE_RUNTIME_METRICS + size_t peak_active; +#endif + uint64_t serial; +#ifdef PURE_RUNTIME_METRICS + uint64_t accepted, commits, gets, conflicts, budget_rejections; + uint64_t releases, borrowed_output_bytes; +#endif + bool accepting, running, async_effects; +}; + +/* Enforce repeated storage cost on 64-bit C hosts, including macOS where the + * Linux object-section checker is unavailable. */ +#if SIZE_MAX == UINT64_MAX && UINTPTR_MAX == UINT64_MAX +_Static_assert(sizeof(PureWork) == 216, "work-slot layout exceeded its reviewed size"); +#ifdef PURE_RUNTIME_METRICS +_Static_assert(sizeof(PureRuntime) == 192, "metrics domain layout changed"); +#else +_Static_assert(sizeof(PureRuntime) == 120, "domain layout changed"); +#endif +#endif + +/* Initialize fresh storage once. A NULL work pointer with capacity zero starts + * an idle domain; bind work before admission. Use restart, not init, to reuse + * a domain so its monotonic ticket sequence survives restart. definition is + * host-owned and remains alive until all work has drained. */ +bool pure_runtime_init(PureRuntime *, PureWork *, size_t capacity, + const void *definition, PureHost, size_t budget); +bool pure_runtime_restart(PureRuntime *, const void *definition, PureHost, size_t budget); +void pure_runtime_stop(PureRuntime *); /* stop admission, allow queued work to drain */ +/* Replace caller-owned work storage only after all tickets and loans retire. + * A zero-capacity binding releases idle slot storage without resetting budget, + * ticket generation or optional metrics. The caller retains the bound array's life. */ +bool pure_runtime_bind(PureRuntime *, PureWork *, size_t capacity); + +/* 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 */ + +#ifdef PURE_ASYNC_EFFECTS +/* Optional owner-thread effect suspension. Enable before accepting work. The + * host may complete effects in any order, but must call complete on the owner + * thread after workers stop accessing its borrowed spans. An in-flight effect + * keeps its input alive even if the caller releases the ticket. Effects + * borrowed by take stay valid until completion. */ +typedef enum { PURE_TAKE_EMPTY, PURE_TAKE_EFFECT } PureTake; +typedef struct { + PureTicket ticket; + PureInput input; + const char *key; + int64_t expected; + PureSpan value; /* raw ingress for WRITE; empty for READ */ + size_t available; /* remaining payload credits for READ */ +} PureEffect; +bool pure_runtime_enable_async(PureRuntime *); +PureTake pure_runtime_take(PureRuntime *, PureEffect *); +/* A valid completion consumes read_resource (even on failure); a rejected call + * leaves it untouched. write_reply borrows from the retained ingress. */ +bool pure_runtime_complete(PureTicket, PureResult status, + PureResource *read_resource, PureSpan write_reply, + int64_t revision); +#endif +#endif diff --git a/prototypes/connections/server.c b/prototypes/connections/server.c new file mode 100644 index 0000000..505bb71 --- /dev/null +++ b/prototypes/connections/server.c @@ -0,0 +1,645 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include "program.h" +#include "store-port.h" +#ifdef PURE_EMBEDDED_PROGRAM +#include "embedded-plan.h" +#endif +#include +#include +#include +#include +#include +#include +#include +#ifdef PURE_HTTP_ASYNC +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(PURE_HTTP_ASYNC) && !defined(PURE_ASYNC_EFFECTS) +#error PURE_HTTP_ASYNC requires PURE_ASYNC_EFFECTS +#endif + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING, WAITING_EFFECT } Phase; +typedef struct Server Server; +#ifdef PURE_HTTP_ASYNC +typedef enum { JOB_FREE, JOB_QUEUED, JOB_RUNNING, JOB_DONE } JobState; +typedef struct { + PureEffect effect; + PureResource resource; + PureSpan reply; + PureResult status; + int64_t revision; + JobState state; +} EffectJob; +typedef struct { Server *server; PureInput input; } EffectWorker; +#endif +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received; + unsigned char *body; + bool put; + int64_t expected; + PureTicket ticket; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; +#ifndef PURE_EMBEDDED_PROGRAM + PureProgram program; +#endif + PureRuntime runtime; + PureWork work[SLOTS]; + size_t budget, active, peak_active; + uint64_t accepted, expired, prefix_copy_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +#ifdef PURE_HTTP_ASYNC + PureHost effect_host; + pthread_t worker[2]; + EffectWorker worker_context[2]; + pthread_mutex_t job_mutex; + pthread_cond_t job_ready[2]; + EffectJob jobs[SLOTS]; + size_t next_job[2]; + int effect_wake; + bool worker_stop; +#endif +}; +static volatile sig_atomic_t stopping; +static volatile sig_atomic_t wake_write = -1; +static void stop_server(int sig) { + (void)sig; + int saved_errno = errno; + stopping = 1; + if (wake_write >= 0) { + const char byte = 1; + (void)write((int)wake_write, &byte, 1); + } + errno = saved_errno; +} + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + if (c->ticket.runtime) { + bool released = pure_runtime_release(c->ticket); + assert(released); + (void)released; + } + c->ticket = (PureTicket){0}; + c->body = NULL; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void answer(Connection *c, PureOutcome result) { + switch (result.status) { + case PURE_OK: respond(c, 200, "OK", "none", result.revision, result.body); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + respond(c, 412, "Precondition Failed", "conflict", result.revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "store_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_ALLOCATION: fail(c, 503, "Service Unavailable", "allocation"); break; + case PURE_CLOSED: fail(c, 503, "Service Unavailable", "stopped"); break; + case PURE_DENIED: fail(c, 403, "Forbidden", "denied"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + case PURE_UNCERTAIN: fail(c, 500, "Internal Server Error", "durability_unknown"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +#ifdef PURE_HTTP_ASYNC +/* Immutable reads may run beside one writer; only the write worker touches + * the store's temporary-name sequence and publication lock. Runtime and + * connections remain on poll's owner thread. Borrowed spans outlive work. */ +static void *effect_worker(void *context) { + EffectWorker *worker = context; + Server *server = worker->server; + PureInput input = worker->input; + for (;;) { + pthread_mutex_lock(&server->job_mutex); + size_t slot = SLOTS; + while (slot == SLOTS) { + for (size_t offset = 0; offset < SLOTS; ++offset) { + size_t candidate = (server->next_job[input] + offset) % SLOTS; + if (server->jobs[candidate].state == JOB_QUEUED && + server->jobs[candidate].effect.input == input) { + slot = candidate; + break; + } + } + if (slot < SLOTS || server->worker_stop) break; + pthread_cond_wait(&server->job_ready[input], &server->job_mutex); + } + if (slot == SLOTS) { pthread_mutex_unlock(&server->job_mutex); return NULL; } + EffectJob *job = &server->jobs[slot]; + server->next_job[input] = (slot + 1) % SLOTS; + job->state = JOB_RUNNING; + PureEffect effect = job->effect; + pthread_mutex_unlock(&server->job_mutex); + + PureResource resource = {0}; + PureSpan reply = {0}; + int64_t revision = 0; + PureHost host = server->effect_host; + PureResult status = effect.input == PURE_INPUT_READ ? + host.read(host.context, server->runtime.definition, effect.key, + effect.available, &resource, &revision) : + host.write(host.context, server->runtime.definition, effect.key, + effect.expected, effect.value, &reply, &revision); + + pthread_mutex_lock(&server->job_mutex); + job->resource = resource; + job->reply = reply; + job->revision = revision; + job->status = status; + job->state = JOB_DONE; + pthread_mutex_unlock(&server->job_mutex); + const char byte = 1; + (void)write(server->effect_wake, &byte, 1); + } +} + +static void finish_effects(Server *server) { + for (size_t i = 0; i < SLOTS; ++i) { + pthread_mutex_lock(&server->job_mutex); + EffectJob *job = &server->jobs[i]; + if (job->state != JOB_DONE) { pthread_mutex_unlock(&server->job_mutex); continue; } + PureTicket ticket = job->effect.ticket; + PureInput input = job->effect.input; + PureResource resource = job->resource; + PureSpan reply = job->reply; + PureResult status = job->status; + int64_t revision = job->revision; + *job = (EffectJob){0}; + pthread_mutex_unlock(&server->job_mutex); + if (!pure_runtime_complete(ticket, status, + input == PURE_INPUT_READ ? &resource : NULL, + reply, revision)) abort(); + for (size_t j = 0; j < SLOTS; ++j) { + Connection *c = &server->connections[j]; + if (c->fd < 0 || c->phase != WAITING_EFFECT || + c->ticket.slot != ticket.slot || c->ticket.generation != ticket.generation) + continue; + PureOutcome result; + if (!pure_runtime_result(ticket, &result)) abort(); + c->body = NULL; + answer(c, result); + break; + } + } +} +#endif + +static void run_request(Connection *c) { + if (c->put && !pure_runtime_ready(c->ticket)) abort(); +#ifdef PURE_HTTP_ASYNC + Server *server = c->server; + PureEffect effect; + PureTake taken = pure_runtime_take(&server->runtime, &effect); + if (taken == PURE_TAKE_EFFECT) { + pthread_mutex_lock(&server->job_mutex); + EffectJob *job = &server->jobs[effect.ticket.slot]; + if (job->state != JOB_FREE) abort(); + *job = (EffectJob){.effect = effect, .state = JOB_QUEUED}; + pthread_cond_signal(&server->job_ready[effect.input]); + pthread_mutex_unlock(&server->job_mutex); + c->phase = WAITING_EFFECT; + return; + } + abort(); +#else + PureOutcome result; + while (!pure_runtime_result(c->ticket, &result)) + if (!pure_runtime_step(&c->server->runtime)) abort(); +#endif + c->body = NULL; /* Core now owns input until all consumers release it. */ +#ifdef PURE_HTTP_ASYNC + PureOutcome result; + if (!pure_runtime_result(c->ticket, &result)) abort(); +#endif + answer(c, result); +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + PureResult admitted = pure_runtime_accept(&c->server->runtime, + c->put ? PURE_INPUT_WRITE : PURE_INPUT_READ, c->key, c->expected, c->body_size, &c->ticket); + if (admitted != PURE_OK) { answer(c, (PureOutcome){.status = admitted}); return; } + if (!c->put) { run_request(c); return; } + c->body = pure_runtime_ingress(c->ticket, NULL); + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +static bool close_on_exec(int fd) { + int flags = fcntl(fd, F_GETFD, 0); + return flags >= 0 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; +#ifndef PURE_EMBEDDED_PROGRAM + const char *program_path = NULL; + bool explain = argc == 3 && !strcmp(argv[1], "--check-program"); + char program_error[160]; + if (explain) program_path = argv[2]; +#else + bool explain = false; +#endif + for (int i = 1; !explain && i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--store")) server.path = argv[i + 1]; +#ifndef PURE_EMBEDDED_PROGRAM + else if (!strcmp(argv[i], "--program")) program_path = argv[i + 1]; +#endif + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!server.path && !explain) goto usage; +#ifndef PURE_EMBEDDED_PROGRAM + if (!program_path) goto usage; + if (!pure_program_load(program_path, &server.program, program_error, sizeof(program_error))) { + fprintf(stderr, "Program: %s\n", program_error); return 2; + } + if (explain) { + PureProgram *p = &server.program; + printf("{\"read\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"write\":{\"to_end\":%s,\"offset\":%zu,\"length\":%zu,\"minimum_source_bytes\":%zu}," + "\"reply_input\":%s,\"view_definitions\":%zu,\"range_definitions\":%zu," + "\"runtime_view_steps\":%zu,\"write_step\":%zu,\"reply_step\":%zu,\"prepared_program_bytes\":%zu,\"steps\":[", + p->read.to_end ? "true" : "false", p->read.offset, p->read.length, p->read_required, + p->write.to_end ? "true" : "false", p->write.offset, p->write.length, p->input_required, + p->reply_input ? "true" : "false", p->view_definitions, p->range_definitions, + p->input_steps, p->write_step, p->reply_step, sizeof(*p)); + for (size_t i = 0; i < p->input_steps; ++i) { + const PureRangeStep *step = &p->steps[i]; + printf("%s{\"source_step\":%zu,\"source\":{\"offset\":%zu,\"length\":%zu,\"to_end\":%s}," + "\"offset\":{\"value\":%zu,\"from_u32le\":%s},\"length\":{\"value\":%zu,\"from_u32le\":%s}}", + i ? "," : "", step->source_step, step->source.offset, step->source.length, + step->source.to_end ? "true" : "false", step->offset.value, step->offset.from_u32le ? "true" : "false", + step->length.value, step->length.from_u32le ? "true" : "false"); + } + printf("],\"write_effect\":\"%s\",\"splice_offset\":{\"value\":%zu,\"from_u32le\":%s}," + "\"splice_remove\":{\"value\":%zu,\"from_u32le\":%s}}\n", + p->splice ? "splice" : "write", p->splice_offset.value, + p->splice_offset.from_u32le ? "true" : "false", p->splice_remove.value, + p->splice_remove.from_u32le ? "true" : "false"); + return 0; + } +#endif + const PureProgram *program = +#ifdef PURE_EMBEDDED_PROGRAM + &pure_embedded_program; +#else + &server.program; +#endif + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize byte store directory\n", stderr); return 1; } + PureHost host = pure_store_host(server.path); +#ifdef PURE_HTTP_ASYNC + server.effect_host = host; + host.read = NULL; + host.write = NULL; +#endif + if (!pure_runtime_init(&server.runtime, server.work, SLOTS, program, + host, server.budget)) return 1; +#ifdef PURE_HTTP_ASYNC + if (!pure_runtime_enable_async(&server.runtime)) abort(); +#endif + signal(SIGPIPE, SIG_IGN); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + int wake[2]; + if (pipe(wake)) { perror("pipe"); close(listener); return 1; } + if (!nonblocking(wake[1]) || !close_on_exec(wake[0]) || !close_on_exec(wake[1]) +#ifdef PURE_HTTP_ASYNC + || !nonblocking(wake[0]) +#endif + ) { + perror("wake pipe"); close(wake[0]); close(wake[1]); close(listener); return 1; + } + wake_write = wake[1]; + struct sigaction stop_action = {.sa_handler = stop_server}; + sigemptyset(&stop_action.sa_mask); + if (sigaction(SIGINT, &stop_action, NULL) || sigaction(SIGTERM, &stop_action, NULL)) { + perror("sigaction"); wake_write = -1; + close(wake[0]); close(wake[1]); close(listener); return 1; + } +#ifdef PURE_HTTP_ASYNC + server.effect_wake = wake[1]; + pthread_attr_t worker_attr; + if (pthread_mutex_init(&server.job_mutex, NULL) || + pthread_cond_init(&server.job_ready[PURE_INPUT_READ], NULL) || + pthread_cond_init(&server.job_ready[PURE_INPUT_WRITE], NULL) || + pthread_attr_init(&worker_attr) || + pthread_attr_setstacksize(&worker_attr, 128u * 1024u)) { + fputs("Cannot initialize bounded storage workers\n", stderr); + wake_write = -1; + close(wake[0]); close(wake[1]); close(listener); return 1; + } + size_t workers_started = 0; + for (size_t i = 0; i < 2; ++i) { + server.worker_context[i] = (EffectWorker){.server = &server, .input = (PureInput)i}; + if (pthread_create(&server.worker[i], &worker_attr, + effect_worker, &server.worker_context[i])) break; + ++workers_started; + } + pthread_attr_destroy(&worker_attr); + if (workers_started != 2) { + pthread_mutex_lock(&server.job_mutex); + server.worker_stop = true; + pthread_cond_signal(&server.job_ready[PURE_INPUT_READ]); + pthread_cond_signal(&server.job_ready[PURE_INPUT_WRITE]); + pthread_mutex_unlock(&server.job_mutex); + for (size_t i = 0; i < workers_started; ++i) pthread_join(server.worker[i], NULL); + pthread_cond_destroy(&server.job_ready[PURE_INPUT_READ]); + pthread_cond_destroy(&server.job_ready[PURE_INPUT_WRITE]); + pthread_mutex_destroy(&server.job_mutex); + fputs("Cannot start bounded storage workers\n", stderr); + wake_write = -1; + close(wake[0]); close(wake[1]); close(listener); return 1; + } +#endif + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"storage\":\"immutable-byte-extents-v1\"}\n", ntohs(address.sin_port)); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 2] = { + {.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}, + {.fd = wake[0], .events = POLLIN} + }; + uint64_t nearest_deadline = UINT64_MAX; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 2] = (struct pollfd){.fd = c->phase == WAITING_EFFECT ? -1 : c->fd, + .events = c->phase == WRITING ? POLLOUT : POLLIN}; + if (c->fd >= 0 && c->deadline < nearest_deadline) nearest_deadline = c->deadline; + } + int wait_ms = -1; + if (nearest_deadline != UINT64_MAX) { + uint64_t now = now_ms(); + uint64_t remaining = nearest_deadline > now ? nearest_deadline - now : 0; + wait_ms = remaining > INT_MAX ? INT_MAX : (int)remaining; + } + int ready = poll(polls, SLOTS + 2, wait_ms); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } +#ifdef PURE_HTTP_ASYNC + if (polls[1].revents & POLLIN) { + char bytes[64]; + while (read(wake[0], bytes, sizeof(bytes)) > 0) {} + } + finish_effects(&server); +#endif + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 2].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } +#ifdef PURE_HTTP_ASYNC + if (c->phase == WAITING_EFFECT) continue; +#endif + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + wake_write = -1; +#ifndef PURE_HTTP_ASYNC + close(wake[0]); + close(wake[1]); +#endif + pure_runtime_stop(&server.runtime); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); +#ifdef PURE_HTTP_ASYNC + pthread_mutex_lock(&server.job_mutex); + server.worker_stop = true; + pthread_cond_signal(&server.job_ready[PURE_INPUT_READ]); + pthread_cond_signal(&server.job_ready[PURE_INPUT_WRITE]); + pthread_mutex_unlock(&server.job_mutex); + for (size_t i = 0; i < 2; ++i) if (pthread_join(server.worker[i], NULL)) abort(); + finish_effects(&server); + close(wake[0]); + close(wake[1]); + pthread_cond_destroy(&server.job_ready[PURE_INPUT_READ]); + pthread_cond_destroy(&server.job_ready[PURE_INPUT_WRITE]); + pthread_mutex_destroy(&server.job_mutex); +#endif + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.runtime.retained, server.runtime.peak_retained, server.budget, server.peak_active, + server.accepted, server.runtime.commits, server.runtime.gets, server.runtime.conflicts, server.runtime.budget_rejections, + server.expired, server.runtime.releases, server.prefix_copy_bytes, server.runtime.borrowed_output_bytes, sizeof(server)); + return 0; +usage: +#ifdef PURE_EMBEDDED_PROGRAM + fputs("pure-connections-fixed --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n", stderr); +#else + fputs("pure-connections --program FILE --store DIRECTORY [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n" + "pure-connections --check-program FILE\n", stderr); +#endif + return 2; +} diff --git a/prototypes/connections/source-tui-budget.json b/prototypes/connections/source-tui-budget.json new file mode 100644 index 0000000..31716a4 --- /dev/null +++ b/prototypes/connections/source-tui-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-source-document-terminal-editor", + "binaries": { + "pure-edit-tui": { + "max_text_bytes": 49152, + "max_data_bytes": 1152, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Optional source-loaded POSIX terminal editor for the same document app. Includes the runtime .pure parser and bounded document browser. Default core, fixed CLI and fixed TUI do not link the parser. These are Linux AArch64/GCC 14 section regression ceilings, not whole-process RAM; check_tui.py measures the selected whole process separately." +} diff --git a/prototypes/connections/store-paged.c b/prototypes/connections/store-paged.c new file mode 100644 index 0000000..89c0148 --- /dev/null +++ b/prototypes/connections/store-paged.c @@ -0,0 +1,529 @@ +#define _DEFAULT_SOURCE +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Selected Linux/16 KiB-page storage experiment. Small values retain the + * existing PUREB001 inline file. Large roots name an append-only page + * generation. Both forms expose one immutable, contiguous mapped read. */ +enum { PAGE = 16384, INLINE_BYTES = 24, ROOT_BYTES = 48, + MAX_PAGES = (PURE_OBJECT_LIMIT + PAGE - 1) / PAGE }; +static const unsigned char magic[8] = {'P','U','R','E','P','0','0','1'}; +static const unsigned char inline_magic[8] = {'P','U','R','E','B','0','0','1'}; +static uint64_t temporary_sequence; + +typedef struct { + int64_t revision; + size_t size; + uint64_t generation; + uint64_t refs[MAX_PAGES]; + bool inline_data; +} Root; + +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 void segment_name(const char *key, uint64_t generation, char name[96]) { + snprintf(name, 96, "dat-%s-%016" PRIx64, key, generation); +} + +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 result; + do { result = fsync(fd); } while (result && errno == EINTR); + return result == 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_bytes(int fd, unsigned char *bytes, size_t length, off_t offset) { + while (length) { + ssize_t n = pread(fd, bytes, length, offset); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) return false; + bytes += (size_t)n; + length -= (size_t)n; + offset += n; + } + return true; +} + +static size_t pages(size_t size) { return (size + PAGE - 1) / PAGE; } + +static size_t segment_limit(size_t size) { + size_t limit = 2 * pages(size) * PAGE; +#ifdef PURE_STORE_TEST + if (limit > 6 * PAGE) limit = 6 * PAGE; +#else + if (limit < 8 * PAGE) limit = 8 * PAGE; +#endif + return limit; +} + +static bool read_root(int fd, Root *root) { + struct stat info; + unsigned char header[ROOT_BYTES]; + if (fstat(fd, &info) || !S_ISREG(info.st_mode) || + !read_bytes(fd, header, INLINE_BYTES, 0)) return false; + uint64_t revision = decode64(header + 8), size = decode64(header + 16); + if (!revision || revision > INT64_MAX || size > PURE_OBJECT_LIMIT) return false; + root->revision = (int64_t)revision; + root->size = (size_t)size; + if (!memcmp(header, inline_magic, sizeof(inline_magic))) { + if (info.st_size != (off_t)(INLINE_BYTES + size)) return false; + root->inline_data = true; + root->generation = 0; + return true; + } + if (memcmp(header, magic, sizeof(magic)) || + !read_bytes(fd, header + INLINE_BYTES, ROOT_BYTES - INLINE_BYTES, INLINE_BYTES)) return false; + uint64_t generation = decode64(header + 24), page = decode64(header + 32); + uint64_t count = decode64(header + 40); + if (!generation || page != PAGE || count != pages((size_t)size) || + info.st_size != (off_t)(ROOT_BYTES + count * 8)) return false; + root->inline_data = false; + root->generation = generation; + unsigned char bytes[MAX_PAGES * 8]; + if (count && !read_bytes(fd, bytes, (size_t)count * 8, ROOT_BYTES)) return false; + for (size_t i = 0; i < (size_t)count; ++i) root->refs[i] = decode64(bytes + i * 8); + return true; +} + +static int open_segment(int directory, const char *key, const Root *root, int flags) { + char name[96]; + segment_name(key, root->generation, name); + int fd = openat(directory, name, flags | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) return -1; + struct stat info; + if (fstat(fd, &info) || !S_ISREG(info.st_mode) || info.st_size < 0) goto bad; + for (size_t i = 0; i < pages(root->size); ++i) + if (root->refs[i] > (uint64_t)INT64_MAX / PAGE || + (uint64_t)info.st_size / PAGE <= root->refs[i]) goto bad; + return fd; +bad: + close(fd); + return -1; +} + +static PureResult open_locked(const char *path, bool writer, int *directory, int *lock) { + *directory = open(path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (*directory < 0) return PURE_STORE_ERROR; + *lock = openat(*directory, ".writer.lock", + (writer ? O_RDWR : O_RDONLY) | O_NOFOLLOW | O_CLOEXEC); + struct stat info; + if (*lock < 0 || fstat(*lock, &info) || !S_ISREG(info.st_mode)) return PURE_STORE_ERROR; + struct flock hold = {.l_type = writer ? F_WRLCK : F_RDLCK, .l_whence = SEEK_SET, + .l_start = 0, .l_len = 0}; + int rc; + do { rc = fcntl(*lock, writer ? F_SETLK : F_SETLKW, &hold); } + while (rc && errno == EINTR && !writer); + if (rc) return writer && (errno == EACCES || errno == EAGAIN) ? PURE_BUSY : PURE_STORE_ERROR; + return PURE_OK; +} + +PureResult pure_store_init(const char *path) { + if (!path || !*path || sysconf(_SC_PAGESIZE) != PAGE) 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; + 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) && 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 = -1, lock = -1, head = -1, data = -1; + PureResult result = open_locked(path, false, &directory, &lock); + if (result != PURE_OK) goto finish; + head = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (head < 0) { result = errno == ENOENT ? PURE_NOT_FOUND : PURE_STORE_ERROR; goto finish; } + Root root; + if (!read_root(head, &root)) { result = PURE_STORE_ERROR; goto finish; } + if (root.size < minimum || offset > root.size) { result = PURE_LIMIT; goto finish; } + if (whole) length = root.size - offset; + if (length > root.size - offset) { result = PURE_LIMIT; goto finish; } + if (length > available) { result = PURE_BUDGET; goto finish; } + if (root.inline_data) { + if (length) { + size_t start = INLINE_BYTES + offset; + size_t aligned = start - start % PAGE; + size_t prefix = start - aligned; + size_t mapped_size = prefix + length; + void *mapped = mmap(NULL, mapped_size, PROT_READ, MAP_PRIVATE, head, (off_t)aligned); + if (mapped == MAP_FAILED) { result = PURE_STORE_ERROR; goto finish; } + out->mapping = mapped; + out->mapped_size = mapped_size; + out->body = (PureSpan){(const unsigned char *)mapped + prefix, length}; + } + } else { + data = open_segment(directory, key, &root, O_RDONLY); + if (data < 0) { result = PURE_STORE_ERROR; goto finish; } + if (length) { + size_t first = offset / PAGE, count = pages(offset % PAGE + length); + size_t mapped_size = count * PAGE; + void *base = mmap(NULL, mapped_size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (base == MAP_FAILED) { result = PURE_STORE_ERROR; goto finish; } + bool valid = true; + for (size_t at = 0; at < count;) { + size_t run = 1; + while (at + run < count && root.refs[first + at + run] == + root.refs[first + at] + run) ++run; + void *address = (unsigned char *)base + at * PAGE; + void *mapped = mmap(address, run * PAGE, PROT_READ, MAP_PRIVATE | MAP_FIXED, + data, (off_t)(root.refs[first + at] * PAGE)); + if (mapped != address) { valid = false; break; } + at += run; + } + if (!valid) { munmap(base, mapped_size); result = PURE_STORE_ERROR; goto finish; } + out->mapping = base; + out->mapped_size = mapped_size; + out->body = (PureSpan){(const unsigned char *)base + offset % PAGE, length}; + } + } + out->revision = root.revision; + out->object_size = root.size; + result = PURE_OK; +finish: + if (data >= 0) close(data); + if (head >= 0) close(head); + if (lock >= 0) close(lock); + if (directory >= 0) close(directory); + 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 read_logical(int data, const Root *root, size_t offset, + unsigned char *destination, size_t length) { + if (root->inline_data) + return read_bytes(data, destination, length, (off_t)(INLINE_BYTES + offset)); + while (length) { + size_t page = offset / PAGE, inside = offset % PAGE; + size_t n = PAGE - inside < length ? PAGE - inside : length; + if (!read_bytes(data, destination, n, (off_t)(root->refs[page] * PAGE + inside))) return false; + destination += n; + offset += n; + length -= n; + } + return true; +} + +static bool copy_logical(int source, const Root *root, int destination, + size_t offset, size_t length) { + unsigned char scratch[4096]; + while (length) { + size_t n = length < sizeof(scratch) ? length : sizeof(scratch); + if (!read_logical(source, root, offset, scratch, n) || + !write_bytes(destination, scratch, n)) return false; + offset += n; + length -= n; + } + return true; +} + +static bool write_full_pages(int old_data, const Root *old, int destination, + bool splice, size_t offset, size_t remove, PureSpan body, + Root *next) { + size_t count = pages(next->size); + for (size_t page = 0; page < count; ++page) { + unsigned char bytes[PAGE] = {0}; + size_t start = page * PAGE, end = start + PAGE; + if (end > next->size) end = next->size; + size_t at = start; + while (at < end) { + size_t n; + if (splice && at < offset) { + n = offset - at < end - at ? offset - at : end - at; + if (!read_logical(old_data, old, at, bytes + at - start, n)) return false; + } else if (at < offset + body.size || !splice) { + size_t source = splice ? at - offset : at; + n = body.size - source < end - at ? body.size - source : end - at; + memcpy(bytes + at - start, body.data + source, n); + } else { + n = end - at; + if (!read_logical(old_data, old, at - body.size + remove, + bytes + at - start, n)) return false; + } + at += n; + } + if (!write_bytes(destination, bytes, PAGE)) return false; + next->refs[page] = page; + } + return true; +} + +static bool append_patch(int data, const Root *old, Root *next, + size_t offset, PureSpan insert) { + if (!insert.size) return true; + struct stat info; + if (fstat(data, &info) || info.st_size < 0 || + (uint64_t)info.st_size > INT64_MAX - PAGE) return false; + off_t start = (info.st_size + PAGE - 1) / PAGE * PAGE; + if (ftruncate(data, start) || lseek(data, start, SEEK_SET) != start) return false; + size_t first = offset / PAGE, end = pages(offset + insert.size); + for (size_t page = first; page < end; ++page) { + unsigned char bytes[PAGE]; + if (!read_bytes(data, bytes, PAGE, (off_t)(old->refs[page] * PAGE))) return false; + size_t begin = page * PAGE < offset ? offset : page * PAGE; + size_t limit = (page + 1) * PAGE > offset + insert.size ? + offset + insert.size : (page + 1) * PAGE; + memcpy(bytes + begin - page * PAGE, insert.data + begin - offset, limit - begin); + if (!write_bytes(data, bytes, PAGE)) return false; + next->refs[page] = (uint64_t)start / PAGE + page - first; + } + return sync_fd(data); +} + +static bool publish_root(int directory, const char *name, Root *next, int64_t *revision, + bool *published, int old_data, const Root *old, + bool splice, size_t offset, size_t remove, PureSpan body) { + char temporary[96] = ""; + int fd = -1; + for (unsigned attempt = 0; attempt < 64; ++attempt) { + if (temporary_sequence == UINT64_MAX) break; + 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; + if (errno != EEXIST) break; + } + if (fd < 0) return false; + unsigned char bytes[ROOT_BYTES + MAX_PAGES * 8]; + memcpy(bytes, next->inline_data ? inline_magic : magic, 8); + encode64(bytes + 8, (uint64_t)next->revision); + encode64(bytes + 16, next->size); + bool ok; + if (next->inline_data) { + ok = write_bytes(fd, bytes, INLINE_BYTES) && !fault(PURE_AFTER_HEADER); + if (ok && splice) + ok = copy_logical(old_data, old, fd, 0, offset) && + write_bytes(fd, body.data, body.size) && + copy_logical(old_data, old, fd, offset + remove, + old->size - offset - remove); + else if (ok) ok = write_bytes(fd, body.data, body.size); + } else { + encode64(bytes + 24, next->generation); + encode64(bytes + 32, PAGE); + encode64(bytes + 40, pages(next->size)); + for (size_t i = 0; i < pages(next->size); ++i) + encode64(bytes + ROOT_BYTES + i * 8, next->refs[i]); + ok = write_bytes(fd, bytes, ROOT_BYTES) && !fault(PURE_AFTER_HEADER) && + write_bytes(fd, bytes + ROOT_BYTES, pages(next->size) * 8); + } + if (ok) ok = !fault(PURE_BEFORE_FILE_SYNC) && sync_fd(fd); + if (close(fd)) ok = false; + if (ok) ok = !fault(PURE_BEFORE_RENAME) && !renameat(directory, temporary, directory, name); + if (ok) { + *published = true; + *revision = next->revision; + temporary[0] = 0; + ok = !fault(PURE_BEFORE_DIR_SYNC) && sync_fd(directory); + } + if (*temporary) unlinkat(directory, temporary, 0); + return ok; +} + +static void collect_old_segments(int directory, const char *key, uint64_t current) { + char prefix[72], retained[96]; + snprintf(prefix, sizeof(prefix), "dat-%s-", key); + segment_name(key, current, retained); + int copy = dup(directory); + if (copy < 0) return; + DIR *entries = fdopendir(copy); + if (!entries) { close(copy); return; } + bool removed = false; + struct dirent *entry; + size_t prefix_size = strlen(prefix); + while ((entry = readdir(entries))) { + if (!strncmp(entry->d_name, ".pending-", 9)) { + if (!unlinkat(directory, entry->d_name, 0)) removed = true; + continue; + } + if (strncmp(entry->d_name, prefix, prefix_size) || + strlen(entry->d_name + prefix_size) != 16 || + !strcmp(entry->d_name, retained)) continue; + bool hexadecimal = true; + for (const char *p = entry->d_name + prefix_size; *p; ++p) + if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f'))) hexadecimal = false; + if (hexadecimal && !unlinkat(directory, entry->d_name, 0)) removed = true; + } + closedir(entries); + if (removed) sync_fd(directory); +} + +static PureResult mutate(const char *path, const char *key, int64_t expected, + PureSpan body, bool splice, size_t offset, size_t remove, + size_t max_size, int64_t *revision) { + *revision = 0; + char name[72], new_segment[96] = ""; + if (!file_name(key, name) || (body.size && !body.data)) return PURE_STORE_ERROR; + if (body.size > PURE_OBJECT_LIMIT || max_size > PURE_OBJECT_LIMIT || expected < 0) return PURE_LIMIT; + int directory = -1, lock = -1, head = -1, old_data = -1, new_data = -1; + Root old = {0}, next = {0}; + bool published = false, reused_segment = false, owned_segment = false; + struct stat info; + PureResult result = open_locked(path, true, &directory, &lock); + if (result != PURE_OK) goto finish; + result = PURE_STORE_ERROR; + head = openat(directory, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + if (head >= 0) { + if (!read_root(head, &old)) goto finish; + old_data = old.inline_data ? dup(head) : open_segment(directory, key, &old, O_RDWR); + if (old_data < 0) goto finish; + } else if (errno != ENOENT) goto finish; + *revision = old.revision; + if (expected != old.revision) { result = PURE_CONFLICT; goto finish; } + if (old.revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + size_t result_size = body.size; + if (splice) { + if (offset > old.size || remove > old.size - offset || + old.size - remove > max_size || + body.size > max_size - (old.size - remove)) { result = PURE_LIMIT; goto finish; } + result_size = old.size - remove + body.size; + } + next = old; + next.revision = old.revision + 1; + next.size = result_size; + next.inline_data = result_size <= PAGE; + if (next.inline_data) next.generation = 0; + bool patch = old.revision && !old.inline_data && !next.inline_data && + splice && remove == body.size && result_size == old.size; + /* A small value retains the existing PUREB001 24-byte header. */ + if (!next.inline_data && patch && fstat(old_data, &info) == 0 && + info.st_size < (off_t)segment_limit(old.size)) { + reused_segment = true; + if (!append_patch(old_data, &old, &next, offset, body)) goto finish; + } else if (!next.inline_data) { + uint64_t candidate = old.generation; + for (unsigned attempt = 0; attempt < 64; ++attempt) { + if (candidate == UINT64_MAX) goto finish; + segment_name(key, ++candidate, new_segment); + new_data = openat(directory, new_segment, + O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (new_data >= 0) { owned_segment = true; break; } + if (errno != EEXIST) goto finish; + } + if (new_data < 0) goto finish; + next.generation = candidate; + if (!write_full_pages(old_data, &old, new_data, splice, offset, remove, body, &next) || + !sync_fd(new_data)) goto finish; + /* A new data file's directory entry must survive before the root + * can durably name it. */ + if (!sync_fd(directory)) goto finish; + } + if (!publish_root(directory, name, &next, revision, &published, + old_data, &old, splice, offset, remove, body)) { + result = published ? PURE_UNCERTAIN : PURE_STORE_ERROR; + goto finish; + } + result = PURE_OK; + /* Readers have mapped each old inode before releasing their shared lock. + * A failed cleanup leaks space but cannot invalidate an existing view. */ + /* An inline-only edit creates no segments. Avoid a directory-wide scan + * on the small-object hot path. */ + if (!reused_segment && (old.generation || next.generation)) + collect_old_segments(directory, key, next.generation); +finish: + if (new_data >= 0) close(new_data); + if (old_data >= 0) close(old_data); + if (head >= 0) close(head); + if (result != PURE_OK && result != PURE_UNCERTAIN && owned_segment) + unlinkat(directory, new_segment, 0); + if (lock >= 0) close(lock); + if (directory >= 0) 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, PURE_OBJECT_LIMIT, revision); +} + +PureResult pure_store_splice(const char *path, const char *key, int64_t expected, + size_t offset, size_t remove, PureSpan insert, + size_t max_size, int64_t *revision) { + return mutate(path, key, expected, insert, true, offset, remove, max_size, revision); +} diff --git a/prototypes/connections/store-port.c b/prototypes/connections/store-port.c new file mode 100644 index 0000000..7bbe682 --- /dev/null +++ b/prototypes/connections/store-port.c @@ -0,0 +1,51 @@ +#include "store-port.h" +#include "store.h" +#include +#include + +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 void *definition, const char *key, + size_t available, PureResource *out, int64_t *revision) { + const PureProgram *program = definition; + if (!program) return PURE_INVALID; + PureStoredView view = {0}; + PureRegion region = program->read; + PureResult result = pure_store_select(context, key, region.offset, region.length, + region.to_end, program->read_required, 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 void *definition, const char *key, + int64_t expected, PureSpan input, PureSpan *reply, + int64_t *revision) { + const PureProgram *program = definition; + if (!program) return PURE_INVALID; + PureSpan value; + if (!pure_program_input(program, input, &value, reply)) return PURE_LIMIT; + if (!program->splice) return pure_store_put(context, key, expected, value, revision); + size_t offset, remove; + if (!pure_program_splice(program, input, &offset, &remove)) return PURE_LIMIT; + return pure_store_splice(context, key, expected, offset, remove, value, + program->splice_limit, revision); +} + +PureHost pure_store_host(const char *path) { + return (PureHost){.context = (void *)path, .allocate = allocate, + .read = read_value, .write = write_value}; +} diff --git a/prototypes/connections/store-port.h b/prototypes/connections/store-port.h new file mode 100644 index 0000000..0b09403 --- /dev/null +++ b/prototypes/connections/store-port.h @@ -0,0 +1,7 @@ +#ifndef PURE_STORE_PORT_H +#define PURE_STORE_PORT_H +#include "runtime.h" +#include "program.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/connections/store.c b/prototypes/connections/store.c new file mode 100644 index 0000000..d9b15a9 --- /dev/null +++ b/prototypes/connections/store.c @@ -0,0 +1,309 @@ +#if defined(PURE_STORE_CFR_COPY) && defined(__linux__) +#define _GNU_SOURCE +#else +#define _POSIX_C_SOURCE 200809L +#endif +#if defined(PURE_STORE_CFR_COPY) && !defined(__linux__) +#error PURE_STORE_CFR_COPY is a Linux-only selected storage experiment +#endif +/* Darwin's strict POSIX namespace hides open flags used by this adapter. */ +#ifdef __APPLE__ +#define _DARWIN_C_SOURCE +#endif +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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) { +#if defined(PURE_STORE_CFR_COPY) && defined(__linux__) + /* Opt-in Linux filesystem copy experiment. Keep the POSIX path for + * reported unsupported operations and for every non-selected build. */ + if (length >= 16384) { + off_t from = (off_t)(HEADER_BYTES + offset); +#ifdef PURE_STORE_TEST + bool unsupported_after_partial = + pure_store_test_fault(PURE_COPY_RANGE_UNSUPPORTED_AFTER_PARTIAL); + bool first_copy = true; +#endif + while (length) { + ssize_t copied; +#ifdef PURE_STORE_TEST + if (unsupported_after_partial && !first_copy) { + errno = EOPNOTSUPP; + copied = -1; + } else { + size_t wanted = unsupported_after_partial && length > 4096 ? 4096 : length; + copied = copy_file_range(source, &from, destination, NULL, wanted, 0); + } + first_copy = false; +#else + copied = copy_file_range(source, &from, destination, NULL, length, 0); +#endif + if (copied > 0) { + offset += (size_t)copied; + length -= (size_t)copied; + continue; + } + if (copied == 0) return false; /* The immutable source was truncated. */ + if (errno == EINTR) continue; + if (errno != ENOSYS && errno != EOPNOTSUPP && errno != EXDEV && + errno != EINVAL) return false; + break; /* Unsupported on this filesystem; finish with read/write. */ + } + } +#endif + 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, + size_t max_size, 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 || max_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 || + old_size - remove > max_size || + body.size > max_size - (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, PURE_OBJECT_LIMIT, revision); +} + +PureResult pure_store_splice(const char *path, const char *key, int64_t expected, + size_t offset, size_t remove, PureSpan insert, + size_t max_size, int64_t *revision) { + return mutate(path, key, expected, insert, true, offset, remove, max_size, revision); +} diff --git a/prototypes/connections/store.h b/prototypes/connections/store.h new file mode 100644 index 0000000..6896360 --- /dev/null +++ b/prototypes/connections/store.h @@ -0,0 +1,48 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include "result.h" +#include + +#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). + * Retained bytes use bounded scratch space or a selected platform file copy; + * never assembles a whole new object in application memory. The immutable + * file is still rewritten. max_size is the final-body policy from the + * prepared app and is checked with the current size under the writer lock. */ +PureResult pure_store_splice(const char *path, const char *key, int64_t expected, + size_t offset, size_t remove, PureSpan insert, + size_t max_size, 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, + PURE_COPY_RANGE_UNSUPPORTED_AFTER_PARTIAL +} PureStoreStage; +#ifdef PURE_STORE_TEST +int pure_store_test_fault(PureStoreStage stage); +#endif +#endif diff --git a/prototypes/connections/test-async-server-gate.c b/prototypes/connections/test-async-server-gate.c new file mode 100644 index 0000000..1db9816 --- /dev/null +++ b/prototypes/connections/test-async-server-gate.c @@ -0,0 +1,21 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include + +/* Test-only gate at the file sync point. The production server never links + * this file or the PURE_STORE_TEST hook. Only the storage worker reaches it. */ +int pure_store_test_fault(PureStoreStage stage) { + static int entered; + if (stage != PURE_BEFORE_FILE_SYNC || entered) return 0; + entered = 1; + const char *signal_fd = getenv("PURE_TEST_GATE_SIGNAL_FD"); + const char *release_fd = getenv("PURE_TEST_GATE_RELEASE_FD"); + if (!signal_fd || !release_fd) return 0; + int signal = atoi(signal_fd), release = atoi(release_fd); + const char byte = 1; + if (write(signal, &byte, 1) != 1) _exit(91); + char released; + if (read(release, &released, 1) != 1) _exit(92); + return 0; +} diff --git a/prototypes/connections/test-async.c b/prototypes/connections/test-async.c new file mode 100644 index 0000000..6efaf0b --- /dev/null +++ b/prototypes/connections/test-async.c @@ -0,0 +1,330 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(test) do { if (!(test)) { fprintf(stderr, "line %d: %s\n", __LINE__, #test); exit(1); } } while (0) + +static const PureProgram identity = { + .read = {.to_end = true}, .write = {.to_end = true}, + .reply = {.to_end = true}, .reply_input = true +}; + +typedef struct { + PureEffect effect; + const PureProgram *definition; + const char *directory; + int start, done; + PureResult status; + PureSpan reply; + int64_t revision; +} Worker; + +static void *write_after_signal(void *context) { + Worker *worker = context; + char byte = 0; + if (read(worker->start, &byte, 1) != 1) worker->status = PURE_STORE_ERROR; + else { + PureHost store = pure_store_host(worker->directory); + worker->status = store.write(store.context, worker->definition, + worker->effect.key, worker->effect.expected, + worker->effect.value, &worker->reply, + &worker->revision); + } + (void)write(worker->done, &byte, 1); + return NULL; +} + +static PureHost async_host(const char *directory) { + PureHost host = pure_store_host(directory); + host.read = NULL; + host.write = NULL; + return host; +} + +static PureTicket submit(PureRuntime *runtime, const char *key, + const void *body, size_t size) { + PureTicket ticket; + CHECK(pure_runtime_accept(runtime, PURE_INPUT_WRITE, key, 0, size, &ticket) == PURE_OK); + if (size) memcpy(pure_runtime_ingress(ticket, NULL), body, size); + CHECK(pure_runtime_ready(ticket)); + return ticket; +} + +static void expect(const char *directory, const char *key, const void *body, size_t size) { + PureStoredView view; + CHECK(pure_store_get(directory, key, 64, &view) == PURE_OK); + CHECK(view.body.size == size && !memcmp(view.body.data, body, size)); + pure_store_release(&view); +} + +static void worker_and_forward(const char *directory) { + PureRuntime source, destination; + PureWork source_slots[2], destination_slot[1]; + CHECK(pure_runtime_init(&source, source_slots, 2, &identity, + async_host(directory), 64)); + PureTicket ignored = {0}; + CHECK(pure_runtime_accept(&source, PURE_INPUT_WRITE, "denied", 0, 1, + &ignored) == PURE_DENIED); + CHECK(pure_runtime_enable_async(&source)); + PureTicket slow = submit(&source, "slow", "alpha", 5); + PureTicket fast = submit(&source, "fast", "beta", 4); + CHECK(!pure_runtime_step(&source)); /* No synchronous host callback. */ + PureEffect deferred; + CHECK(pure_runtime_take(&source, &deferred) == PURE_TAKE_EFFECT); + CHECK(deferred.ticket.generation == slow.generation && + deferred.value.size == 5 && !memcmp(deferred.value.data, "alpha", 5)); + CHECK(!pure_runtime_cancel(slow)); + int start_pipe[2], done_pipe[2]; + CHECK(!pipe(start_pipe) && !pipe(done_pipe)); + Worker worker = {.effect = deferred, .definition = &identity, .directory = directory, + .start = start_pipe[0], .done = done_pipe[1]}; + pthread_t thread; + CHECK(!pthread_create(&thread, NULL, write_after_signal, &worker)); + CHECK(pure_runtime_release(slow)); + CHECK(source.active == 2 && source.retained == 9); + PureEffect immediate; + CHECK(pure_runtime_take(&source, &immediate) == PURE_TAKE_EFFECT); + int64_t revision = 0; + PureHost store = pure_store_host(directory); + PureSpan reply = {0}; + PureResult written = store.write(store.context, &identity, immediate.key, + immediate.expected, immediate.value, &reply, &revision); + CHECK(pure_runtime_complete(immediate.ticket, written, NULL, reply, revision)); + PureOutcome result; + CHECK(pure_runtime_result(fast, &result) && result.status == PURE_OK && + result.body.size == 4 && !memcmp(result.body.data, "beta", 4)); + CHECK(pure_runtime_release(fast)); + expect(directory, "fast", "beta", 4); + PureStoredView absent; + CHECK(pure_store_get(directory, "slow", 64, &absent) == PURE_NOT_FOUND); + pure_runtime_stop(&source); + CHECK(!pure_runtime_restart(&source, &identity, async_host(directory), 64)); + CHECK(pure_runtime_accept(&source, PURE_INPUT_READ, "fast", 0, 0, + &ignored) == PURE_CLOSED); + CHECK(write(start_pipe[1], "x", 1) == 1); + struct pollfd done = {.fd = done_pipe[0], .events = POLLIN}; + CHECK(poll(&done, 1, 5000) == 1 && (done.revents & POLLIN)); + CHECK(!pthread_join(thread, NULL)); + CHECK(pure_runtime_complete(deferred.ticket, worker.status, NULL, + worker.reply, worker.revision)); + CHECK(!pure_runtime_complete(deferred.ticket, PURE_OK, NULL, (PureSpan){0}, 1)); + CHECK(!pure_runtime_result(slow, &result)); /* Caller released before I/O ended. */ + CHECK(source.active == 0 && source.retained == 0 && source.commits == 2); + expect(directory, "slow", "alpha", 5); + CHECK(!close(start_pipe[0]) && !close(start_pipe[1]) && + !close(done_pipe[0]) && !close(done_pipe[1])); + CHECK(pure_runtime_restart(&source, &identity, async_host(directory), 64)); + + CHECK(pure_runtime_init(&destination, destination_slot, 1, &identity, + async_host(directory), 64)); + CHECK(pure_runtime_enable_async(&destination)); + PureTicket read_ticket; + CHECK(pure_runtime_accept(&source, PURE_INPUT_READ, "fast", 0, 0, + &read_ticket) == PURE_OK); + PureEffect read_effect; + CHECK(pure_runtime_take(&source, &read_effect) == PURE_TAKE_EFFECT && + read_effect.input == PURE_INPUT_READ); + PureResource mapping = {0}; + revision = 0; + PureResult read_status = store.read(store.context, &identity, read_effect.key, + read_effect.available, &mapping, &revision); + CHECK(pure_runtime_complete(read_effect.ticket, read_status, &mapping, + (PureSpan){0}, revision)); + CHECK(!mapping.allocation); + CHECK(pure_runtime_result(read_ticket, &result) && result.status == PURE_OK && + result.body.size == 4); + PureTicket forwarded; + CHECK(pure_runtime_forward(read_ticket, &destination, "copy", 0, + &forwarded) == PURE_OK); + const unsigned char *borrowed = result.body.data; + CHECK(pure_runtime_release(read_ticket)); + CHECK(source.active == 1 && source.retained == 4); + PureEffect output; + CHECK(pure_runtime_take(&destination, &output) == PURE_TAKE_EFFECT); + CHECK(output.value.data == borrowed && output.value.size == 4); + revision = 0; + reply = (PureSpan){0}; + written = store.write(store.context, &identity, output.key, output.expected, + output.value, &reply, &revision); + CHECK(pure_runtime_complete(output.ticket, written, NULL, reply, revision)); + CHECK(pure_runtime_result(forwarded, &result) && result.status == PURE_OK && + result.body.data == borrowed); + CHECK(pure_runtime_release(forwarded)); + CHECK(source.active == 0 && source.retained == 0 && + destination.active == 0 && destination.retained == 0); + expect(directory, "copy", "beta", 4); +} + +static void rejection_and_budget(const char *directory) { + PureProgram invalid = identity; + invalid.input_required = 4; + PureRuntime runtime; + PureWork slots[2]; + CHECK(pure_runtime_init(&runtime, slots, 2, &invalid, + async_host(directory), 6)); + CHECK(pure_runtime_enable_async(&runtime)); + PureTicket rejected = submit(&runtime, "bad", "x", 1); + PureEffect effect; + CHECK(pure_runtime_take(&runtime, &effect) == PURE_TAKE_EFFECT); + PureHost store = pure_store_host(directory); + PureSpan reply = {0}; + int64_t revision = 0; + PureResult status = store.write(store.context, &invalid, effect.key, + effect.expected, effect.value, &reply, &revision); + CHECK(status == PURE_LIMIT); + CHECK(pure_runtime_complete(effect.ticket, status, NULL, reply, revision)); + PureOutcome result; + CHECK(pure_runtime_result(rejected, &result) && result.status == PURE_LIMIT); + CHECK(!pure_runtime_complete(rejected, PURE_OK, NULL, (PureSpan){0}, 1)); + CHECK(pure_runtime_release(rejected)); + CHECK(runtime.active == 0 && runtime.retained == 0); + + CHECK(pure_runtime_restart(&runtime, &identity, async_host(directory), 6)); + PureTicket cancelled = submit(&runtime, "unused", "x", 1); + CHECK(pure_runtime_cancel(cancelled)); + CHECK(pure_runtime_result(cancelled, &result) && result.status == PURE_CANCELLED); + CHECK(pure_runtime_release(cancelled)); + PureTicket invalid_read; + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_READ, "fast", 0, 0, + &invalid_read) == PURE_OK); + PureEffect invalid_effect; + CHECK(pure_runtime_take(&runtime, &invalid_effect) == PURE_TAKE_EFFECT); + PureResource foreign = {.body = {(const unsigned char *)"x", 1}}; + CHECK(!pure_runtime_complete(invalid_effect.ticket, PURE_OK, &foreign, + (PureSpan){0}, 1)); + CHECK(foreign.body.size == 1 && runtime.active == 1); + CHECK(pure_runtime_complete(invalid_effect.ticket, PURE_STORE_ERROR, NULL, + (PureSpan){0}, 0)); + CHECK(pure_runtime_release(invalid_read)); + PureTicket bad_reply = submit(&runtime, "bad_reply", "x", 1); + PureEffect bad_effect; + CHECK(pure_runtime_take(&runtime, &bad_effect) == PURE_TAKE_EFFECT); + CHECK(!pure_runtime_complete(bad_effect.ticket, PURE_OK, NULL, + (PureSpan){(const unsigned char *)"outside", 7}, 1)); + CHECK(runtime.active == 1 && runtime.retained == 1); + CHECK(pure_runtime_complete(bad_effect.ticket, PURE_STORE_ERROR, NULL, + (PureSpan){0}, 0)); + CHECK(pure_runtime_release(bad_reply) && runtime.active == 0 && runtime.retained == 0); + PureTicket first, second; + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_READ, "fast", 0, 0, &first) == PURE_OK); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_READ, "fast", 0, 0, &second) == PURE_OK); + PureEffect one, two; + CHECK(pure_runtime_take(&runtime, &one) == PURE_TAKE_EFFECT); + CHECK(pure_runtime_take(&runtime, &two) == PURE_TAKE_EFFECT); + CHECK(pure_runtime_take(&runtime, &effect) == PURE_TAKE_EMPTY); + PureResource a = {0}, b = {0}; + int64_t revision_a = 0, revision_b = 0; + CHECK(store.read(store.context, &identity, one.key, one.available, + &a, &revision_a) == PURE_OK); + CHECK(store.read(store.context, &identity, two.key, two.available, + &b, &revision_b) == PURE_OK); + CHECK(pure_runtime_complete(two.ticket, PURE_OK, &b, (PureSpan){0}, revision_b)); + CHECK(!b.allocation); + CHECK(pure_runtime_complete(one.ticket, PURE_OK, &a, (PureSpan){0}, revision_a)); + CHECK(!a.allocation); + CHECK(pure_runtime_result(first, &result) && result.status == PURE_BUDGET); + CHECK(pure_runtime_result(second, &result) && result.status == PURE_OK); + CHECK(runtime.budget_rejections == 1 && runtime.retained == 4); + CHECK(pure_runtime_release(first) && pure_runtime_release(second)); + CHECK(runtime.active == 0 && runtime.retained == 0); +} + +static void authored_document(const char *directory) { + PureProgram program; + char error[160]; + CHECK(pure_program_load("examples/editor.pure", &program, error, sizeof(error))); + PureRuntime runtime; + PureWork slot[1]; + CHECK(pure_runtime_init(&runtime, slot, 1, &program, + async_host(directory), 64)); + CHECK(pure_runtime_enable_async(&runtime)); + const unsigned char create[] = {0,0,0,0, 0,0,0,0, 'H','i'}; + PureTicket ticket = submit(&runtime, "document", create, sizeof(create)); + PureEffect effect; + CHECK(pure_runtime_take(&runtime, &effect) == PURE_TAKE_EFFECT && + effect.value.size == sizeof(create)); + PureSpan value, reply = {0}; + size_t offset, remove; + CHECK(pure_program_splice(&program, effect.value, &offset, &remove) && + pure_program_input(&program, effect.value, &value, &reply) && + offset == 0 && remove == 0 && value.size == 2 && + !memcmp(value.data, "Hi", 2)); + PureHost store = pure_store_host(directory); + int64_t revision = 0; + PureResult status = store.write(store.context, &program, effect.key, + effect.expected, effect.value, &reply, &revision); + CHECK(pure_runtime_complete(effect.ticket, status, NULL, reply, revision)); + PureOutcome result; + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_OK && + result.body.size == 2 && !memcmp(result.body.data, "Hi", 2)); + CHECK(pure_runtime_release(ticket)); + + const unsigned char append[] = {2,0,0,0, 0,0,0,0, '!'}; + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "document", 1, + sizeof(append), &ticket) == PURE_OK); + memcpy(pure_runtime_ingress(ticket, NULL), append, sizeof(append)); + CHECK(pure_runtime_ready(ticket)); + CHECK(pure_runtime_take(&runtime, &effect) == PURE_TAKE_EFFECT && + effect.value.size == sizeof(append)); + CHECK(pure_program_splice(&program, effect.value, &offset, &remove) && + offset == 2 && remove == 0); + reply = (PureSpan){0}; + status = store.write(store.context, &program, effect.key, effect.expected, + effect.value, &reply, &revision); + CHECK(pure_runtime_complete(effect.ticket, status, NULL, reply, revision)); + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_OK && + result.body.size == 1 && result.body.data[0] == '!'); + CHECK(pure_runtime_release(ticket)); + expect(directory, "document", "Hi!", 3); + + ticket = submit(&runtime, "document", append, sizeof(append)); + CHECK(pure_runtime_take(&runtime, &effect) == PURE_TAKE_EFFECT); + reply = (PureSpan){0}; + status = store.write(store.context, &program, effect.key, effect.expected, + effect.value, &reply, &revision); + CHECK(pure_runtime_complete(effect.ticket, status, NULL, reply, revision)); + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_CONFLICT && + !result.body.size); + CHECK(pure_runtime_release(ticket)); + expect(directory, "document", "Hi!", 3); + + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_READ, "document", 0, 0, + &ticket) == PURE_OK); + CHECK(pure_runtime_take(&runtime, &effect) == PURE_TAKE_EFFECT); + PureResource mapping = {0}; + status = store.read(store.context, &program, effect.key, + effect.available, &mapping, &revision); + CHECK(pure_runtime_complete(effect.ticket, status, &mapping, + (PureSpan){0}, revision)); + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_OK && + result.body.size == 3 && !memcmp(result.body.data, "Hi!", 3)); + CHECK(pure_runtime_release(ticket)); + CHECK(runtime.active == 0 && runtime.retained == 0 && + runtime.commits == 2 && runtime.conflicts == 1 && runtime.gets == 1); +} + +int main(void) { + char template[] = "/tmp/pure-async-effects-XXXXXX"; + char *directory = mkdtemp(template); + CHECK(directory && pure_store_init(directory) == PURE_OK); + worker_and_forward(directory); + rejection_and_budget(directory); + authored_document(directory); + DIR *opened = opendir(directory); + CHECK(opened); + struct dirent *entry; + while ((entry = readdir(opened))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(opened), entry->d_name, 0) == 0); + } + CHECK(!closedir(opened) && !rmdir(directory)); + puts("{\"async_effects\":\"authored-document-worker-overlap-release-forward-budget-recovery\"}"); +} diff --git a/prototypes/connections/test-fields.c b/prototypes/connections/test-fields.c new file mode 100644 index 0000000..63dfc3e --- /dev/null +++ b/prototypes/connections/test-fields.c @@ -0,0 +1,153 @@ +#include "program.h" +#include +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static const char layout[] = "layout Envelope { marker: bytes[1]; start: u32le; count: u32le; text: bytes; }"; +static PureProgram parse(const char *source) { + PureProgram p; + char error[160]; + if (!pure_program_parse(source, strlen(source), &p, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return p; +} +static void invalid(const char *source) { + PureProgram p; + memset(&p, 0x5a, sizeof(p)); + unsigned char before[sizeof(p)]; + memcpy(before, &p, sizeof(p)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(*error && !memcmp(before, &p, sizeof(p))); +} +static void encode(unsigned char *at, uint32_t value) { + for (unsigned i = 0; i < 4; ++i) at[i] = (unsigned char)(value >> (8 * i)); +} +int main(void) { + char source[8000]; + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let alias = input; let start = alias.start; let text = alias.text; " + "let selected = range(text, start, alias.count); let result = selected; " + "let saved = store.write(request.key, request.revision, result); reply(saved, selected); }", layout, get); + PureProgram p = parse(source); + CHECK(p.input_steps == 1 && p.input_required == 9 && p.write_step == 1 && p.reply_step == 1); + CHECK(p.steps[0].source.offset == 9 && p.steps[0].source.to_end); + CHECK(p.steps[0].offset.value == 1 && p.steps[0].offset.from_u32le); + CHECK(p.steps[0].length.value == 5 && p.steps[0].length.from_u32le); + unsigned char bytes[609]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 13); + PureSpan stored, returned; + for (size_t n = 0; n < 9; ++n) { + CHECK(!pure_program_input(&p, (PureSpan){bytes, n}, &stored, &returned)); + CHECK(!stored.size && !returned.size); + } + const uint32_t offsets[] = {0, 1, 255, 257, 599, 600, 601, UINT32_MAX}; + const uint32_t counts[] = {0, 1, 2, 260, 600, UINT32_MAX}; + size_t comparisons = 0; + for (size_t i = 0; i < sizeof(offsets) / sizeof(*offsets); ++i) + for (size_t j = 0; j < sizeof(counts) / sizeof(*counts); ++j) { + uint32_t start = offsets[i], count = counts[j]; + encode(bytes + 1, start); + encode(bytes + 5, count); + bool expected = start <= 600 && count <= 600 - start; + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned) == expected); + if (expected) { + CHECK(stored.size == count && returned.size == count && stored.data == returned.data); + CHECK(!count || stored.data == bytes + 9 + start); + } else CHECK(!stored.size && !returned.size); + ++comparisons; + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let first = range(input.text, input.start, input.count); let second = range(first, 1, 2); " + "let saved = store.write(request.key, request.revision, second); reply(saved, second); }", layout, get); + p = parse(source); + CHECK(p.input_steps == 2 && p.steps[1].source_step == 1); + encode(bytes + 1, 3); encode(bytes + 5, 4); + CHECK(pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + CHECK(stored.size == 2 && stored.data == bytes + 13); + encode(bytes + 5, 2); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let unused = range(input.text, input.start, input.count); " + "let saved = store.write(request.key, request.revision, input.text); reply(saved); }", layout, get); + p = parse(source); + encode(bytes + 1, UINT32_MAX); + CHECK(!pure_program_input(&p, (PureSpan){bytes, sizeof(bytes)}, &stored, &returned)); + + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); " + "let input = view(Envelope, o.body); reply(o, input.text); } %s", layout, put); + p = parse(source); + CHECK(p.read.to_end && p.read.offset == 9 && p.read_required == 9 && !p.input_steps); + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let selection = range(input.text, 2, 3); let saved = store.write(request.key, request.revision, selection); reply(saved); }", layout, get); + p = parse(source); + CHECK(!p.input_steps && p.write.offset == 11 && p.write.length == 3 && p.input_required == 14); + snprintf(source, sizeof(source), "layout Empty { body: bytes; } %s on put { let input = view(Empty, request.body); " + "let saved = store.write(request.key, request.revision, input.body); reply(saved, input.body); }", get); + p = parse(source); + CHECK(pure_program_input(&p, (PureSpan){0}, &stored, &returned) && !stored.size && !returned.size); + + const char *bad_layout[] = { + "layout E {}", "layout E { x: bytes; y: u32le; }", "layout E { x: u32le; x: bytes; }", + "layout E { x: bytes[1449985]; }", "layout E { x: bytes[1449984]; y: u32le; }", + "layout E { x: bytes[184467440737095516160]; }", "layout E { x: u64le; }", + "layout E { x: u32le; } layout E { y: bytes; }" + }; + for (size_t i = 0; i < sizeof(bad_layout) / sizeof(*bad_layout); ++i) { + snprintf(source, sizeof(source), "%s %s %s", bad_layout[i], get, put); invalid(source); + } + const char *bad_input[] = { + "let input = view(Missing, request.body);", + "let input = view(Envelope, request.body); let x = input.missing;", + "let input = view(Envelope, request.body); let x = range(input.text, input.text, 1);", + "let short = range(request.body, 0, 8); let input = view(Envelope, short);", + "let input = view(Envelope, request.body); let x = range(input.text, input.start, 20); let y = view(Envelope, x);" + }; + for (size_t i = 0; i < sizeof(bad_input) / sizeof(*bad_input); ++i) { + snprintf(source, sizeof(source), "%s %s on put { %s let saved = store.write(request.key, request.revision, request.body); reply(saved); }", layout, get, bad_input[i]); + invalid(source); + } + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); " + "let saved = store.write(request.key, request.revision, input.start); reply(saved); }", layout, get); + invalid(source); + snprintf(source, sizeof(source), "%s on get { let o = store.read(request.key); let input = view(Envelope, o.body); " + "let x = range(input.text, input.start, input.count); reply(o, x); } %s", layout, put); + invalid(source); + for (size_t count = 8; count <= 9; ++count) { + snprintf(source, sizeof(source), "%s %s on put { let input = view(Envelope, request.body); ", layout, get); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let v%zu = range(input.text, input.start, input.count); ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let saved = store.write(request.key, request.revision, input.text); reply(saved); }"); + if (count == 8) CHECK(parse(source).input_steps == 8); else invalid(source); + } + for (size_t count = 4; count <= 5; ++count) { + source[0] = 0; + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "layout L%zu { body: bytes; } ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "%s %s", get, put); + if (count == 4) (void)parse(source); else invalid(source); + } + for (size_t count = 16; count <= 17; ++count) { + strcpy(source, "layout L { "); + for (size_t i = 0; i < count; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "f%zu: u32le; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "} %s %s", get, put); + if (count == 16) (void)parse(source); else invalid(source); + } + printf("{\"fields\":\"named-layouts-unaligned-u32le-tail-dynamic-ranges-aliases-failures-limits\"," + "\"data_boundary_cases\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/test-flow.c b/prototypes/connections/test-flow.c new file mode 100644 index 0000000..5ff028d --- /dev/null +++ b/prototypes/connections/test-flow.c @@ -0,0 +1,183 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static int failed_stage = -1; +static bool crash_at_stage; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != failed_stage) return 0; + if (crash_at_stage) _exit(77); + return 1; +} +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.mapping && !first.mapped_size); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.mapping && !first.mapped_size); + CHECK(pure_store_get_range(path, "work", 1, 3, 3, &first) == PURE_OK); + CHECK(first.object_size == sizeof(original) && first.body.size == 3 && !memcmp(first.body.data, original + 1, 3)); + pure_store_release(&first); + CHECK(pure_store_get_range(path, "work", SIZE_MAX, 1, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, SIZE_MAX, 10, &first) == PURE_LIMIT); + CHECK(pure_store_get_range(path, "work", 1, 3, 2, &first) == PURE_BUDGET); + CHECK(pure_store_get_range(path, "work", sizeof(original), 0, 0, &first) == PURE_OK); + CHECK(!first.mapped_size && !first.body.size); + pure_store_release(&first); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_UNCERTAIN); + CHECK(revision == 3); + failed_stage = -1; + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 2, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_CONFLICT); + + /* A crashed unpublished writer leaves only a private file. A new publisher + * must still use the previous committed version, never an incomplete body. */ + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + failed_stage = PURE_AFTER_HEADER; + crash_at_stage = true; + pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision); + _exit(78); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + CHECK(pure_store_init(path) == PURE_OK); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 3 && !memcmp(first.body.data, original, sizeof(original))); + pure_store_release(&first); + CHECK(pure_store_put(path, "work", 3, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 4); + + char file[320]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + CHECK(fd >= 0); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); + + /* Range mapping crosses page boundaries without copying a whole object. */ + long page = sysconf(_SC_PAGESIZE); + CHECK(page >= 128 && (size_t)page <= PURE_OBJECT_LIMIT / 3); + size_t size = (size_t)page * 3, offset = (size_t)page + 13, length = (size_t)page + 105; + unsigned char *bytes = malloc(size); + CHECK(bytes); + for (size_t i = 0; i < size; ++i) bytes[i] = (unsigned char)((i * 17u) ^ (i >> 8) ^ (i >> 16)); + CHECK(pure_store_put(path, "range", 0, (PureSpan){bytes, size}, &revision) == PURE_OK); + CHECK(pure_store_get_range(path, "range", offset, length, length, &first) == PURE_OK); + CHECK(first.body.size == length && !memcmp(first.body.data, bytes + offset, length)); + pure_store_release(&first); + free(bytes); + snprintf(file, sizeof(file), "%s/obj-broken", path); + fd = open(file, O_CREAT | O_WRONLY | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, "incomplete", 10) == 10 && close(fd) == 0); + CHECK(pure_store_get(path, "broken", 100, &first) == PURE_STORE_ERROR && !first.mapped_size); + CHECK(pure_store_put(path, "broken", 0, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_init(file) == PURE_STORE_ERROR); + struct stat info; + CHECK(stat(file, &info) == 0 && info.st_size == 10); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", directory); + storage(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"mapped-ranges-snapshot-budget-conflict-failure-uncertain-crash-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/test-paged-store.c b/prototypes/connections/test-paged-store.c new file mode 100644 index 0000000..aed0539 --- /dev/null +++ b/prototypes/connections/test-paged-store.c @@ -0,0 +1,270 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum { PAGE = 16384, SIZE = 3 * PAGE + 23 }; +static int fail_stage = -1; +static int crash; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != fail_stage) return 0; + if (crash) _exit(77); + return 1; +} + +static void expect(const char *path, int64_t revision, const unsigned char *bytes) { + PureStoredView view; + assert(pure_store_get(path, "large", SIZE, &view) == PURE_OK); + assert(view.revision == revision && view.body.size == SIZE && + !memcmp(view.body.data, bytes, SIZE)); + pure_store_release(&view); +} + +static uint32_t random_value(uint32_t *state) { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + return *state; +} + +static void expect_root(const char *path, const char *key, + const char magic[8], off_t size) { + char filename[320]; + snprintf(filename, sizeof(filename), "%s/obj-%s", path, key); + struct stat info; + assert(stat(filename, &info) == 0 && info.st_size == size); + int fd = open(filename, O_RDONLY); + assert(fd >= 0); + char actual[8]; + assert(read(fd, actual, sizeof(actual)) == sizeof(actual)); + assert(!memcmp(actual, magic, sizeof(actual))); + assert(close(fd) == 0); +} + +static void format_transitions(const char *path) { + unsigned char bytes[PAGE + 1]; + memset(bytes, 'A', sizeof(bytes)); + int64_t revision; + assert(pure_store_put(path, "boundary", 0, (PureSpan){bytes, PAGE}, + &revision) == PURE_OK); + expect_root(path, "boundary", "PUREB001", 24 + PAGE); + PureStoredView old; + assert(pure_store_get(path, "boundary", PAGE + 1, &old) == PURE_OK); + assert(pure_store_splice(path, "boundary", revision, PAGE, 0, + (PureSpan){bytes, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expect_root(path, "boundary", "PUREP001", 48 + 2 * 8); + assert(old.body.size == PAGE && old.body.data[PAGE - 1] == 'A'); + pure_store_release(&old); + PureStoredView large; + assert(pure_store_get(path, "boundary", PAGE + 1, &large) == PURE_OK); + assert(large.body.size == PAGE + 1 && large.body.data[PAGE] == 'A'); + assert(pure_store_splice(path, "boundary", revision, PAGE, 1, + (PureSpan){NULL, 0}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expect_root(path, "boundary", "PUREB001", 24 + PAGE); + assert(large.body.size == PAGE + 1 && large.body.data[PAGE] == 'A'); + pure_store_release(&large); + PureStoredView current; + assert(pure_store_get(path, "boundary", PAGE, ¤t) == PURE_OK); + assert(current.revision == 3 && current.body.size == PAGE && + !memcmp(current.body.data, bytes, PAGE)); + pure_store_release(¤t); + + const unsigned char one = 'X', two = 'Y'; + assert(pure_store_put(path, "tiny", 0, (PureSpan){&one, 1}, &revision) == PURE_OK); + expect_root(path, "tiny", "PUREB001", 25); + assert(pure_store_splice(path, "tiny", revision, 1, 0, + (PureSpan){&two, 1}, 1, &revision) == PURE_LIMIT); + assert(revision == 1); + PureStoredView policy_view; + assert(pure_store_get(path, "tiny", 1, &policy_view) == PURE_OK); + assert(policy_view.revision == 1 && policy_view.body.size == 1 && policy_view.body.data[0] == one); + pure_store_release(&policy_view); + fail_stage = PURE_BEFORE_RENAME; + assert(pure_store_splice(path, "tiny", revision, 0, 1, + (PureSpan){&two, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_STORE_ERROR); + fail_stage = -1; + assert(pure_store_get(path, "tiny", 1, ¤t) == PURE_OK); + assert(current.revision == 1 && current.body.data[0] == one); + pure_store_release(¤t); + fail_stage = PURE_BEFORE_DIR_SYNC; + assert(pure_store_splice(path, "tiny", revision, 0, 1, + (PureSpan){&two, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_UNCERTAIN); + fail_stage = -1; + assert(pure_store_get(path, "tiny", 1, ¤t) == PURE_OK); + assert(current.revision == 2 && current.body.data[0] == two); + pure_store_release(¤t); + + /* An existing flat-store object has no data segment. Its first large + * edit must read the old inline body before publishing a page root. */ + char legacy_file[320]; + snprintf(legacy_file, sizeof(legacy_file), "%s/obj-legacy", path); + int fd = open(legacy_file, O_WRONLY | O_CREAT | O_EXCL, 0600); + assert(fd >= 0); + unsigned char header[24] = "PUREB001"; + header[8] = 1; + header[16] = 1; + header[17] = 0x40; /* 0x4001 = 16 KiB + 1 */ + assert(write(fd, header, sizeof(header)) == sizeof(header)); + assert(write(fd, bytes, sizeof(bytes)) == sizeof(bytes)); + assert(close(fd) == 0); + assert(pure_store_get(path, "legacy", PAGE + 1, ¤t) == PURE_OK); + assert(current.revision == 1 && current.body.size == PAGE + 1); + pure_store_release(¤t); + assert(pure_store_splice(path, "legacy", 1, PAGE, 1, + (PureSpan){&two, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expect_root(path, "legacy", "PUREP001", 48 + 2 * 8); + assert(pure_store_get(path, "legacy", PAGE + 1, ¤t) == PURE_OK); + assert(current.revision == 2 && current.body.size == PAGE + 1 && + current.body.data[PAGE] == two); + pure_store_release(¤t); +} + +static void mixed_edits(const char *path) { + enum { CAPACITY = 6 * PAGE }; + unsigned char *oracle = malloc(CAPACITY), *before = malloc(CAPACITY); + unsigned char insert[4096]; + assert(oracle && before); + size_t length = 2 * PAGE + 17; + uint32_t state = 0xA17E532Du; + for (size_t i = 0; i < length; ++i) oracle[i] = (unsigned char)random_value(&state); + int64_t revision; + assert(pure_store_put(path, "mixed", 0, (PureSpan){oracle, length}, &revision) == PURE_OK); + for (unsigned step = 0; step < 128; ++step) { + size_t offset = random_value(&state) % (length + 1); + size_t remove = random_value(&state) % 4097; + if (remove > length - offset) remove = length - offset; + size_t count = step % 3 ? random_value(&state) % 4097 : remove; + if (count > CAPACITY - (length - remove)) count = CAPACITY - (length - remove); + for (size_t i = 0; i < count; ++i) insert[i] = (unsigned char)random_value(&state); + PureStoredView old; + assert(pure_store_get(path, "mixed", CAPACITY, &old) == PURE_OK); + if (length) memcpy(before, old.body.data, length); + assert(pure_store_splice(path, "mixed", revision, offset, remove, + (PureSpan){insert, count}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + assert(old.body.size == length && + (!length || !memcmp(old.body.data, before, length))); + pure_store_release(&old); + memmove(oracle + offset + count, oracle + offset + remove, length - offset - remove); + memcpy(oracle + offset, insert, count); + length = length - remove + count; + PureStoredView current; + assert(pure_store_get(path, "mixed", CAPACITY, ¤t) == PURE_OK); + assert(current.revision == (int64_t)step + 2 && current.body.size == length && + (!length || !memcmp(current.body.data, oracle, length))); + pure_store_release(¤t); + } + free(before); + free(oracle); +} + +int main(void) { + assert(sysconf(_SC_PAGESIZE) == PAGE); + char base[] = "/tmp/pure-page-store-XXXXXX"; + assert(mkdtemp(base)); + char path[256]; + snprintf(path, sizeof(path), "%s/store", base); + assert(pure_store_init(path) == PURE_OK); + unsigned char expected[SIZE]; + for (size_t i = 0; i < SIZE; ++i) expected[i] = (unsigned char)(i * 17u + i / PAGE); + int64_t revision; + assert(pure_store_put(path, "large", 0, (PureSpan){expected, SIZE}, &revision) == PURE_OK); + assert(revision == 1); + PureStoredView old; + assert(pure_store_get(path, "large", SIZE, &old) == PURE_OK); + unsigned char original = old.body.data[PAGE - 1]; + const unsigned char replacement = 0xC7; + assert(pure_store_splice(path, "large", revision, PAGE - 1, 1, + (PureSpan){&replacement, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expected[PAGE - 1] = replacement; + assert(revision == 2 && old.body.data[PAGE - 1] == original); + expect(path, revision, expected); + PureStoredView range; + assert(pure_store_get_range(path, "large", PAGE - 3, 9, 9, &range) == PURE_OK); + assert(!memcmp(range.body.data, expected + PAGE - 3, 9)); + pure_store_release(&range); + char file[320]; + struct stat info; + snprintf(file, sizeof(file), "%s/dat-large-%016x", path, 1); + assert(stat(file, &info) == 0 && info.st_size == (off_t)(5 * PAGE)); + + /* The bounded append log compacts into a new generation. The old view + * remains valid even after the old generation has been unlinked. */ + for (unsigned i = 0; i < 3; ++i) { + unsigned char value = (unsigned char)(0xA0 + i); + assert(pure_store_splice(path, "large", revision, SIZE - 1, 1, + (PureSpan){&value, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expected[SIZE - 1] = value; + expect(path, revision, expected); + } + assert(access(file, F_OK) != 0 && old.body.data[PAGE - 1] == original); + pure_store_release(&old); + + /* Unpublished crashes may leave an orphan generation. They never change + * the visible root; the next writer must skip the collided generation. */ + pid_t child = fork(); + assert(child >= 0); + if (!child) { + fail_stage = PURE_BEFORE_RENAME; + crash = 1; + pure_store_put(path, "large", revision, (PureSpan){expected, SIZE}, &revision); + _exit(78); + } + int status; + assert(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + assert(pure_store_init(path) == PURE_OK); + expect(path, revision, expected); + unsigned char value = 0x5A; + assert(pure_store_splice(path, "large", revision, 0, 1, + (PureSpan){&value, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expected[0] = value; + expect(path, revision, expected); + + fail_stage = PURE_BEFORE_DIR_SYNC; + value = 0x36; + assert(pure_store_splice(path, "large", revision, PAGE + 4, 1, + (PureSpan){&value, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_UNCERTAIN); + fail_stage = -1; + expected[PAGE + 4] = value; + expect(path, revision, expected); + + /* A whole replacement must skip the generation orphaned by the crash. */ + assert(pure_store_put(path, "large", revision, + (PureSpan){expected, SIZE}, &revision) == PURE_OK); + expect(path, revision, expected); + DIR *segments = opendir(path); + assert(segments); + unsigned generations = 0; + struct dirent *segment; + while ((segment = readdir(segments))) { + assert(strncmp(segment->d_name, ".pending-", 9)); + if (!strncmp(segment->d_name, "dat-large-", 10)) ++generations; + } + assert(closedir(segments) == 0 && generations == 1); + const unsigned char initial[] = "abcde", inserted[] = "XYZ"; + assert(pure_store_put(path, "variable", 0, + (PureSpan){initial, 5}, &revision) == PURE_OK); + assert(pure_store_splice(path, "variable", revision, 1, 2, + (PureSpan){inserted, 3}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + PureStoredView variable; + assert(pure_store_get(path, "variable", 6, &variable) == PURE_OK); + assert(variable.body.size == 6 && !memcmp(variable.body.data, "aXYZde", 6)); + pure_store_release(&variable); + format_transitions(path); + mixed_edits(path); + DIR *directory = opendir(path); + assert(directory); + struct dirent *entry; + while ((entry = readdir(directory))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + assert(unlinkat(dirfd(directory), entry->d_name, 0) == 0); + } + assert(closedir(directory) == 0 && rmdir(path) == 0 && rmdir(base) == 0); + puts("{\"hybrid_storage\":\"snapshot-patch-range-compaction-crash-conflict-uncertain-inline-transitions\",\"mixed_edits\":128}"); +} diff --git a/prototypes/connections/test-program.c b/prototypes/connections/test-program.c new file mode 100644 index 0000000..6a4a19d --- /dev/null +++ b/prototypes/connections/test-program.c @@ -0,0 +1,111 @@ +#include "program.h" +#include +#include +#include +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +static const char get[] = "on get { let o = store.read(request.key); reply(o, o.body); }"; +static const char put[] = "on put { let s = store.write(request.key, request.revision, request.body); reply(s); }"; +static PureProgram parse(const char *source) { + PureProgram program; + char error[160]; + if (!pure_program_parse(source, strlen(source), &program, error, sizeof(error))) { + fprintf(stderr, "%s\n", error); exit(1); + } + return program; +} +static void invalid(const char *source) { + PureProgram program; + memset(&program, 0x5a, sizeof(program)); + unsigned char original[sizeof(program)]; + memcpy(original, &program, sizeof(program)); + char error[160]; + CHECK(!pure_program_parse(source, strlen(source), &program, error, sizeof(error))); + CHECK(*error && !memcmp(original, &program, sizeof(program))); +} +int main(void) { + char source[4096]; + snprintf(source, sizeof(source), "%s %s", get, put); + PureProgram plan = parse(source); + CHECK(plan.read.to_end && plan.write.to_end && !plan.reply_input); + CHECK(!plan.view_definitions && !plan.read_required && !plan.input_required); + snprintf(source, sizeof(source), "%s on put { let a = range(request.body, 2, 10); " + "let b = range(a, 3, 4); let alias = b; " + "let saved = store.write(request.key, request.revision, alias); reply(saved, b); }", get); + plan = parse(source); + CHECK(plan.write.offset == 5 && plan.write.length == 4 && !plan.write.to_end); + CHECK(plan.reply.offset == 5 && plan.reply.length == 4 && plan.reply_input); + CHECK(plan.input_required == 12 && plan.range_definitions == 2 && plan.view_definitions == 3); + /* Ordered reference views keep even unused bounds failures observable. */ + unsigned char bytes[160]; + for (size_t i = 0; i < sizeof(bytes); ++i) bytes[i] = (unsigned char)(i * 7); + size_t comparisons = 0; + for (size_t a = 0; a < 4; ++a) for (size_t b = 0; b < 6; ++b) + for (size_t c = 0; c <= b; ++c) for (size_t d = 0; d <= b - c; ++d) { + size_t unused_end = (a + b + c) % 12; + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); " + "let unused = range(o.body, %zu, 0); let first = range(o.body, %zu, %zu); " + "let second = range(first, %zu, %zu); reply(o, second); } %s", + unused_end, a, b, c, d, put); + plan = parse(source); + for (size_t n = 0; n < 16; ++n) { + bool reference_ok = unused_end <= n && a <= n && b <= n - a; + PureSpan selected; + bool prepared_ok = n >= plan.read_required && + pure_region_span(plan.read, (PureSpan){bytes, n}, &selected); + CHECK(reference_ok == prepared_ok); + if (reference_ok) { + CHECK(selected.size == d); + CHECK(!d || !memcmp(selected.data, bytes + a + c, d)); + } + ++comparisons; + } + } + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); reply(o); } %s", put); + plan = parse(source); + CHECK(!plan.read.to_end && !plan.read.length); + const char *bad_get[] = { + "on get { let o = store.read(request.key); let x = range(o.body, 0, 2); let y = range(x, 2, 1); reply(o, y); }", + "on get { let o = store.read(request.key); let x = range(o.body, 184467440737095516160, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let x = range(o.body, 1449984, 1); reply(o, x); }", + "on get { let o = store.read(request.key); let o = o.body; reply(o); }", + "on get { let o = store.read(request.key); let x = x; reply(o, x); }", + "on get { let o = store.read(request.key); let x = store.read(request.key); reply(x); }", + "on get { let o = store.read(request.key); reply(o, request.body); }", + "on get { let o = store.read(request.key); let x = o.body; reply(x); }", + "on get { let o = store.read(request.key); reply(o.body); }", + "on get { let o = store.read(request.key); reply(o); let x = o.body; }", + "on get { let o = store.read(request.key); reply(o);", "on get {}" + }; + for (size_t i = 0; i < sizeof(bad_get) / sizeof(*bad_get); ++i) { + snprintf(source, sizeof(source), "%s %s", bad_get[i], put); invalid(source); + } + const char *bad_put[] = { + "on put { let s = store.write(request.key, request.revision, request.body); let b = range(request.body, 0, 1); reply(s, b); }", + "on put { let s = store.write(request.key, request.revision, request.body); reply(s, s.body); }", + "on put { let s = store.read(request.key); reply(s); }" + }; + for (size_t i = 0; i < sizeof(bad_put) / sizeof(*bad_put); ++i) { + snprintf(source, sizeof(source), "%s %s", get, bad_put[i]); invalid(source); + } + invalid(get); + snprintf(source, sizeof(source), "%s %s %s", get, get, put); invalid(source); + snprintf(source, sizeof(source), "%s %s garbage", get, put); invalid(source); + snprintf(source, sizeof(source), "on get { let o = store.read(request.key); "); + for (size_t i = 0; i < 32; ++i) { + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "let x%zu = o.body; ", i); + } + size_t used = strlen(source); + snprintf(source + used, sizeof(source) - used, "reply(o); } %s", put); invalid(source); + char oversized[PURE_PROGRAM_LIMIT + 1]; + memset(oversized, ' ', sizeof(oversized)); + char error[160]; + CHECK(!pure_program_parse(oversized, sizeof(oversized), &plan, error, sizeof(error))); + const char embedded[] = "on get\0{}"; + CHECK(!pure_program_parse(embedded, sizeof(embedded) - 1, &plan, error, sizeof(error))); + PureSpan selected; + CHECK(pure_region_span((PureRegion){0}, (PureSpan){0}, &selected) && !selected.data && !selected.size); + printf("{\"program\":\"prepare-views-preserve-guards-types-effects-and-atomic-install\"," + "\"boundary_semantic_comparisons\":%zu}\n", comparisons); + return 0; +} diff --git a/prototypes/connections/test-runtime.c b/prototypes/connections/test-runtime.c new file mode 100644 index 0000000..0883ec8 --- /dev/null +++ b/prototypes/connections/test-runtime.c @@ -0,0 +1,356 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include +#include + +static const PureProgram identity = { + .read = {.to_end = true}, .write = {.to_end = true}, + .reply = {.to_end = true}, .reply_input = true +}; + +static void remove_store(const char *path) { + DIR *directory = opendir(path); + assert(directory); + struct dirent *entry; + while ((entry = readdir(directory))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + assert(unlinkat(dirfd(directory), entry->d_name, 0) == 0); + } + assert(closedir(directory) == 0 && rmdir(path) == 0); +} +typedef struct { + size_t allocations, writes, frees, live; + bool allocation_failure, invalid_allocation, oversized_read, foreign_reply, reenter, reenter_release; + PureRuntime *runtime; + PureTicket release_ticket; + unsigned char received[256]; + size_t received_size; +} Host; +typedef struct { Host *host; unsigned char bytes[]; } Allocation; + +static void free_input(void *pointer, size_t size) { + Allocation *a = pointer; + assert(size > 0 && a->host->live > 0); + if (a->host->reenter_release) { + PureTicket ignored = {0}; + assert(!pure_runtime_release(a->host->release_ticket)); + assert(pure_runtime_accept(a->host->runtime, PURE_INPUT_WRITE, + "reenter", 0, 0, &ignored) == PURE_INVALID); + } + ++a->host->frees; + --a->host->live; + free(a); +} + +static bool allocate(void *context, size_t size, PureResource *out) { + Host *host = context; + if (host->allocation_failure) return false; + Allocation *a = malloc(sizeof(*a) + size); + assert(a); + a->host = host; + ++host->allocations; + ++host->live; + *out = (PureResource){.body = {a->bytes, size + (host->invalid_allocation ? 1u : 0u)}, .allocation = a, + .allocation_size = size, .release = free_input}; + return true; +} + +static PureResult write_value(void *context, const void *definition, const char *key, + int64_t expected, PureSpan input, PureSpan *reply, + int64_t *revision) { + Host *host = context; + (void)key; + PureSpan body; + if (!pure_program_input(definition, input, &body, reply)) return PURE_LIMIT; + if (host->foreign_reply) { + *reply = (PureSpan){(const unsigned char *)"outside", 7}; + *revision = 1; + return PURE_OK; + } + if (host->reenter) { + PureTicket ignored; + assert(pure_runtime_accept(host->runtime, PURE_INPUT_WRITE, "reenter", 0, 0, &ignored) == PURE_INVALID); + assert(!pure_runtime_step(host->runtime)); + } + if (expected == 99) { *revision = 1; return PURE_CONFLICT; } + ++host->writes; + assert(body.size <= sizeof(host->received)); + if (body.size) memcpy(host->received, body.data, body.size); + host->received_size = body.size; + *revision = 1; + return PURE_OK; +} + +static PureResult read_value(void *context, const void *definition, const char *key, + size_t available, PureResource *out, int64_t *revision) { + Host *host = context; + (void)definition; (void)key; + if (!host->oversized_read) return PURE_NOT_FOUND; + assert(available < 1000); + assert(allocate(host, available + 1, out)); + *revision = 1; + return PURE_OK; /* Deliberately violates the available-credit contract. */ +} + +static PureHost port(Host *host) { + return (PureHost){.context = host, .allocate = allocate, .read = read_value, .write = write_value}; +} + +static PureTicket submit(PureRuntime *r, const char *key, const void *bytes, size_t length) { + PureTicket ticket; + assert(pure_runtime_accept(r, PURE_INPUT_WRITE, key, 0, length, &ticket) == PURE_OK); + size_t capacity; + unsigned char *input = pure_runtime_ingress(ticket, &capacity); + assert(capacity == length); + if (length) memcpy(input, bytes, length); + assert(pure_runtime_ready(ticket)); + assert(!pure_runtime_ready(ticket)); + return ticket; +} + +static PureOutcome completed(PureRuntime *r, PureTicket ticket) { + PureOutcome result; + while (!pure_runtime_result(ticket, &result)) assert(pure_runtime_step(r)); + return result; +} + +static void admission_and_lifecycle(void) { + Host host = {0}; + PureRuntime r; + PureWork work[3]; + assert(pure_runtime_init(&r, work, 3, &identity, port(&host), 5)); + host.runtime = &r; + PureTicket a, untouched = {0}, out = untouched; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "../bad", 0, 1, &out) == PURE_INVALID); + assert(!out.runtime && !host.allocations); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "a", 0, 4, &a) == PURE_OK); + assert(r.retained == 4 && !pure_runtime_step(&r) && !host.writes); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "b", 0, 2, &out) == PURE_BUDGET); + assert(!out.runtime && host.allocations == 1); + host.reenter_release = true; + host.release_ticket = a; + assert(pure_runtime_cancel(a)); + host.reenter_release = false; + PureOutcome cancelled; + assert(pure_runtime_result(a, &cancelled) && cancelled.status == PURE_CANCELLED); + assert(r.retained == 0 && host.frees == 1 && !pure_runtime_cancel(a)); + assert(pure_runtime_release(a) && !pure_runtime_release(a)); + assert(!pure_runtime_result(a, &cancelled)); + + PureTicket first = submit(&r, "first", "1", 1); + PureTicket middle = submit(&r, "middle", "2", 1); + PureTicket last = submit(&r, "last", "3", 1); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "full", 0, 0, &out) == PURE_BUSY); + assert(pure_runtime_cancel(middle)); + assert(pure_runtime_release(middle)); + pure_runtime_stop(&r); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "closed", 0, 0, &out) == PURE_CLOSED); + assert(!pure_runtime_restart(&r, &identity, port(&host), 5)); + host.reenter = true; + assert(completed(&r, first).status == PURE_OK && host.received[0] == '1'); + assert(completed(&r, last).status == PURE_OK && host.received[0] == '3'); + assert(!pure_runtime_step(&r)); + assert(pure_runtime_release(first) && pure_runtime_release(last)); + assert(r.active == 0 && r.retained == 0 && host.live == 0); + assert(pure_runtime_restart(&r, &identity, port(&host), 5)); + PureTicket next = submit(&r, "next", "4", 1); + assert(next.generation > last.generation && !pure_runtime_ready(a)); + assert(!pure_runtime_release(last)); + host.reenter_release = true; + host.release_ticket = next; + assert(pure_runtime_release(next)); /* queued input: no effect */ + host.reenter_release = false; + assert(host.writes == 2 && !pure_runtime_step(&r)); + + host.allocation_failure = true; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "no_memory", 0, 1, &out) == PURE_ALLOCATION); + assert(r.active == 0 && r.retained == 0); + host.allocation_failure = false; + host.invalid_allocation = host.reenter_release = true; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "bad_allocation", 0, 1, &out) == PURE_ALLOCATION); + assert(r.active == 0 && r.retained == 0 && host.live == 0); + host.invalid_allocation = host.reenter_release = false; + PureHost denied = port(&host); + denied.write = NULL; + assert(pure_runtime_restart(&r, &identity, denied, 5)); + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "denied", 0, 1, &out) == PURE_DENIED); + assert(pure_runtime_restart(&r, &identity, port(&host), 5)); + host.oversized_read = true; + assert(pure_runtime_accept(&r, PURE_INPUT_READ, "bad_host", 0, 0, &out) == PURE_OK); + assert(completed(&r, out).status == PURE_BUDGET && host.live == 0 && r.retained == 0); + assert(pure_runtime_release(out)); + host.foreign_reply = true; + PureTicket invalid_reply = submit(&r, "bad_reply", "x", 1); + assert(completed(&r, invalid_reply).status == PURE_STORE_ERROR); + assert(r.retained == 0 && host.live == 0 && pure_runtime_release(invalid_reply)); + host.foreign_reply = false; + r.serial = UINT64_MAX; + assert(pure_runtime_accept(&r, PURE_INPUT_WRITE, "exhausted", 0, 0, &out) == PURE_LIMIT); +} + +static void forwarding(void) { + Host a = {0}, b = {0}, c = {0}; + PureRuntime source, left, right; + PureWork source_work[1], left_work[1], right_work[1]; + assert(pure_runtime_init(&source, source_work, 1, &identity, port(&a), 8)); + assert(pure_runtime_init(&left, left_work, 1, &identity, port(&b), 2)); + assert(pure_runtime_init(&right, right_work, 1, &identity, port(&c), 8)); + PureTicket original = submit(&source, "original", "value", 5), x = {0}, y; + PureOutcome result = completed(&source, original); + assert(result.status == PURE_OK); + const unsigned char *backing = result.body.data; + assert(pure_runtime_forward(original, &left, "left", 0, &x) == PURE_BUDGET); + assert(!x.runtime && source.work[0].pins == 0); + assert(pure_runtime_restart(&left, &identity, port(&b), 8)); + assert(pure_runtime_forward(original, &left, "left", 0, &x) == PURE_OK); + assert(pure_runtime_forward(original, &right, "right", 0, &y) == PURE_OK); + assert(pure_runtime_release(original)); + assert(!pure_runtime_result(original, &result)); + pure_runtime_stop(&source); + assert(!pure_runtime_restart(&source, &identity, port(&a), 8)); + assert(a.live == 1 && !b.allocations && !c.allocations); + assert(pure_runtime_cancel(x) && pure_runtime_release(x)); + assert(a.live == 1 && !b.writes); + result = completed(&right, y); + assert(result.body.data == backing && result.body.size == 5 && !memcmp(result.body.data, "value", 5)); + assert(c.writes == 1 && !c.allocations && source.retained == 5); + assert(!pure_runtime_cancel(y)); /* effects cannot be rolled back */ + assert(pure_runtime_release(y)); + assert(a.frees == 1 && !source.active && !source.retained && !right.active && !right.retained); + assert(pure_runtime_restart(&source, &identity, port(&a), 8)); + + /* A completed consumer does not retain its input when the program has no + * input-backed reply. The producing domain can then be reclaimed early. */ + PureProgram no_reply = identity; + no_reply.reply_input = false; + assert(pure_runtime_restart(&right, &no_reply, port(&c), 8)); + original = submit(&source, "again", "last", 4); + assert(completed(&source, original).status == PURE_OK); + assert(pure_runtime_forward(original, &right, "no_reply", 0, &y) == PURE_OK); + assert(pure_runtime_release(original)); + assert(completed(&right, y).body.size == 0 && a.live == 0); + assert(source.active == 0 && right.active == 1 && right.retained == 0); + assert(pure_runtime_release(y)); +} + +static void idle_binding(void) { + Host a = {0}, b = {0}; + PureRuntime source, destination; + PureWork source_work[1], destination_work[1]; + PureTicket first = {0}; + assert(pure_runtime_init(&source, NULL, 0, &identity, port(&a), 8)); + assert(pure_runtime_init(&destination, destination_work, 1, &identity, port(&b), 8)); + assert(pure_runtime_accept(&source, PURE_INPUT_WRITE, "idle", 0, 1, &first) == PURE_BUSY); + assert(!first.runtime && !pure_runtime_bind(&source, NULL, 1)); + assert(pure_runtime_bind(&source, source_work, 1)); + first = submit(&source, "source", "abc", 3); + assert(!pure_runtime_bind(&source, NULL, 0)); + assert(completed(&source, first).status == PURE_OK); + PureTicket receiver; + assert(pure_runtime_forward(first, &destination, "receiver", 0, &receiver) == PURE_OK); + assert(pure_runtime_release(first)); + assert(!pure_runtime_bind(&source, NULL, 0)); /* The receiver still pins its backing. */ + assert(completed(&destination, receiver).status == PURE_OK); + assert(pure_runtime_release(receiver)); + assert(!source.active && !source.retained && !a.live); + assert(pure_runtime_bind(&source, NULL, 0)); + assert(!pure_runtime_result(first, &(PureOutcome){0})); + assert(pure_runtime_bind(&source, source_work, 1)); + PureTicket second = submit(&source, "again", "d", 1); + assert(second.generation > first.generation && !pure_runtime_ready(first)); + assert(completed(&source, second).status == PURE_OK); + assert(pure_runtime_release(second)); + assert(pure_runtime_bind(&source, NULL, 0)); + assert(source.accepted == 2 && source.releases == 2 && source.serial == second.generation); +} + +static void long_chain(void) { + enum { COUNT = 1024 }; + PureWork *work = calloc(COUNT, sizeof(*work)); + assert(work); + Host host = {0}; + PureRuntime r; + assert(pure_runtime_init(&r, work, COUNT, &identity, port(&host), COUNT)); + PureTicket ticket = submit(&r, "chain", "x", 1); + for (size_t i = 1; i < COUNT; ++i) { + assert(completed(&r, ticket).status == PURE_OK); + PureTicket next; + assert(pure_runtime_forward(ticket, &r, "chain", 0, &next) == PURE_OK); + assert(pure_runtime_release(ticket)); + ticket = next; + } + assert(completed(&r, ticket).status == PURE_OK); + assert(host.allocations == 1 && r.active == COUNT); + assert(pure_runtime_release(ticket)); + assert(r.retained == 0 && r.active == 0 && host.frees == 1); + free(work); +} + +static void actual_storage(void) { + char directory[] = "/tmp/pure-runtime-check-XXXXXX"; + assert(mkdtemp(directory)); + char source_path[128], target_path[128]; + snprintf(source_path, sizeof(source_path), "%s/source", directory); + snprintf(target_path, sizeof(target_path), "%s/target", directory); + assert(pure_store_init(source_path) == PURE_OK && pure_store_init(target_path) == PURE_OK); + PureProgram selection; + char error[160]; + assert(pure_program_load("examples/selection.pure", &selection, error, sizeof(error))); + PureRuntime source, target; + PureWork a[2], b[2]; + assert(pure_runtime_init(&source, a, 2, &selection, pure_store_host(source_path), 128)); + assert(pure_runtime_init(&target, b, 2, &identity, pure_store_host(target_path), 128)); + const unsigned char bytes[] = {1,0,0,0, 3,0,0,0, 'a','b','c','d','e'}; + PureTicket ticket = submit(&source, "excerpt", bytes, sizeof(bytes)), consumer; + PureOutcome result = completed(&source, ticket); + assert(result.status == PURE_OK && result.body.size == 3 && !memcmp(result.body.data, "bcd", 3)); + assert(pure_runtime_forward(ticket, &target, "archive", 0, &consumer) == PURE_OK); + assert(pure_runtime_release(ticket)); + assert(source.active == 1 && source.retained == sizeof(bytes)); + assert(completed(&target, consumer).status == PURE_OK); + assert(pure_runtime_release(consumer) && source.active == 0); + PureStoredView stored = {0}; + assert(pure_store_get(target_path, "archive", 128, &stored) == PURE_OK); + assert(stored.body.size == 3 && !memcmp(stored.body.data, "bcd", 3)); + pure_store_release(&stored); + + assert(pure_runtime_accept(&source, PURE_INPUT_READ, "excerpt", 0, 0, &ticket) == PURE_OK); + assert(completed(&source, ticket).status == PURE_OK); + assert(pure_runtime_forward(ticket, &target, "snapshot", 0, &consumer) == PURE_OK); + assert(pure_runtime_release(ticket)); + int64_t revision; + assert(pure_store_put(source_path, "excerpt", 1, (PureSpan){(const unsigned char *)"new", 3}, &revision) == PURE_OK); + result = completed(&target, consumer); + assert(result.body.size == 3 && !memcmp(result.body.data, "bcd", 3)); + assert(pure_runtime_release(consumer)); + + unsigned char invalid[] = {255,255,255,255, 1,0,0,0, 'x'}; + ticket = submit(&source, "invalid", invalid, sizeof(invalid)); + assert(completed(&source, ticket).status == PURE_LIMIT); + assert(pure_runtime_release(ticket)); + assert(pure_store_get(source_path, "invalid", 128, &stored) == PURE_NOT_FOUND); + assert(source.active == 0 && target.active == 0 && source.retained == 0 && target.retained == 0); + remove_store(source_path); + remove_store(target_path); + assert(rmdir(directory) == 0); +} + +int main(void) { + admission_and_lifecycle(); + forwarding(); + idle_binding(); + long_chain(); + actual_storage(); + printf("{\"runtime_tests\":\"passed\",\"domain_bytes\":%zu,\"work_slot_bytes\":%zu," + "\"ticket_bytes\":%zu,\"prepared_program_bytes\":%zu,\"forwarding_chain\":1024," + "\"native_storage_pipeline\":true,\"idle_slot_binding\":true," + "\"release_reentrancy_guard\":true}\n", sizeof(PureRuntime), sizeof(PureWork), + sizeof(PureTicket), sizeof(PureProgram)); + return 0; +} diff --git a/prototypes/connections/test-splice.c b/prototypes/connections/test-splice.c new file mode 100644 index 0000000..bf3c0a9 --- /dev/null +++ b/prototypes/connections/test-splice.c @@ -0,0 +1,190 @@ +#define _POSIX_C_SOURCE 200809L +#include "runtime.h" +#include "store-port.h" +#include "store.h" +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { fprintf(stderr, "line %d: %s\n", __LINE__, #x); exit(1); } } while (0) +static int failed_stage = -1; +int pure_store_test_fault(PureStoreStage stage) { return (int)stage == failed_stage; } + +static void expect(const char *path, const char *key, int64_t revision, PureSpan bytes) { + PureStoredView view; + CHECK(pure_store_get(path, key, PURE_OBJECT_LIMIT, &view) == PURE_OK); + CHECK(view.revision == revision && view.body.size == bytes.size); + CHECK(!bytes.size || !memcmp(view.body.data, bytes.data, bytes.size)); + pure_store_release(&view); +} + +static void parser(void) { + const char *get = "on get { let o = store.read(request.key); reply(o, o.body); }"; + const char *layout = "layout E { marker: bytes[1]; remove: u32le; start: u32le; insert: bytes; }"; + char source[2048], error[160]; + PureProgram p; + snprintf(source, sizeof(source), "%s %s on put { let e = view(E, request.body); " + "let s = store.splice(request.key, request.revision, e.start, e.remove, e.insert); reply(s, e.insert); }", layout, get); + CHECK(pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(p.splice && p.input_required == 9 && p.splice_offset.value == 5 && p.splice_remove.value == 1); + CHECK(p.splice_limit == PURE_OBJECT_LIMIT); + unsigned char input[] = {0, 2,0,0,0, 3,0,0,0, 0,255}; + PureSpan write, reply; + size_t offset, remove; + for (size_t n = 0; n < 9; ++n) + CHECK(!pure_program_input(&p, (PureSpan){input, n}, &write, &reply)); + CHECK(pure_program_input(&p, (PureSpan){input, sizeof(input)}, &write, &reply)); + CHECK(write.size == 2 && write.data == input + 9 && reply.data == write.data); + CHECK(pure_program_splice(&p, (PureSpan){input, sizeof(input)}, &offset, &remove)); + CHECK(offset == 3 && remove == 2); + + snprintf(source, sizeof(source), "%s on put { let s = store.splice(request.key, request.revision, 0, 0, request.body); reply(s); }", get); + CHECK(pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(pure_program_splice(&p, (PureSpan){0}, &offset, &remove) && offset == 0 && remove == 0); + snprintf(source, sizeof(source), "%s on put { let s = store.splice(request.key, request.revision, 0, 0, request.body, 8); reply(s); }", get); + CHECK(pure_program_parse(source, strlen(source), &p, error, sizeof(error)) && p.splice_limit == 8); + const char *invalid[] = { + "let s = store.splice(request.key, request.revision, e.insert, 0, e.insert); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 1449985, e.insert); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 0, e.start); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 0, e.insert); let t = store.write(request.key, request.revision, e.insert); reply(t);", + "let s = store.splice(request.key, request.revision, 0, 0, e.insert, e.start); reply(s);", + "let s = store.splice(request.key, request.revision, 0, 0, e.insert, 1449985); reply(s);" + }; + for (size_t i = 0; i < sizeof(invalid) / sizeof(*invalid); ++i) { + snprintf(source, sizeof(source), "%s %s on put { let e = view(E, request.body); %s }", layout, get, invalid[i]); + unsigned char before[sizeof(p)]; + memcpy(before, &p, sizeof(p)); + CHECK(!pure_program_parse(source, strlen(source), &p, error, sizeof(error))); + CHECK(*error && !memcmp(before, &p, sizeof(p))); + } +} + +static void storage(const char *path) { + /* Reference whole-value assembly exists only in this test oracle. */ + unsigned char oracle[65536], before[65536], insert[9000]; + for (size_t i = 0; i < sizeof(insert); ++i) insert[i] = (unsigned char)(i * 19u); + size_t length = 49157; + for (size_t i = 0; i < length; ++i) oracle[i] = (unsigned char)((i * 17u) ^ (i >> 8)); + int64_t revision; + CHECK(pure_store_splice(path, "work", 0, 0, 0, (PureSpan){oracle, length}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + CHECK(revision == 1); +#if defined(PURE_STORE_CFR_COPY) && defined(__linux__) + /* A short kernel copy followed by an unsupported result must continue + * from exactly the remaining source and destination byte positions. */ + unsigned char replacement = 'Z'; + failed_stage = PURE_COPY_RANGE_UNSUPPORTED_AFTER_PARTIAL; + CHECK(pure_store_splice(path, "work", revision, 32768, 1, + (PureSpan){&replacement, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + failed_stage = -1; + oracle[32768] = replacement; + expect(path, "work", revision, (PureSpan){oracle, length}); +#endif + /* Prefix/suffix copies cross both scratch and mapping page boundaries. */ + const size_t edits[][3] = {{16381, 7, 9000}, {0, 0, 5}, {50000, 1, 0}, {4095, 4097, 3}, {0, 0, 0}}; + for (size_t i = 0; i < sizeof(edits) / sizeof(*edits); ++i) { + size_t offset = edits[i][0], remove = edits[i][1], count = edits[i][2], previous = length; + memcpy(before, oracle, length); + PureStoredView old; + CHECK(pure_store_get(path, "work", PURE_OBJECT_LIMIT, &old) == PURE_OK); + int64_t expected = revision; + CHECK(pure_store_splice(path, "work", expected, offset, remove, (PureSpan){insert, count}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + CHECK(revision == expected + 1); + memmove(oracle + offset + count, oracle + offset + remove, length - offset - remove); + memcpy(oracle + offset, insert, count); + length = length - remove + count; + expect(path, "work", revision, (PureSpan){oracle, length}); + CHECK(old.body.size == previous && !memcmp(old.body.data, before, previous)); + pure_store_release(&old); + } + int64_t latest = revision; + CHECK(pure_store_splice(path, "work", latest - 1, 0, 0, (PureSpan){insert, 1}, PURE_OBJECT_LIMIT, &revision) == PURE_CONFLICT); + CHECK(revision == latest); + CHECK(pure_store_splice(path, "work", latest, SIZE_MAX, 0, (PureSpan){0}, PURE_OBJECT_LIMIT, &revision) == PURE_LIMIT); + CHECK(pure_store_splice(path, "work", latest, 0, SIZE_MAX, (PureSpan){0}, PURE_OBJECT_LIMIT, &revision) == PURE_LIMIT); + CHECK(pure_store_splice(path, "work", latest, length + 1, 0, (PureSpan){0}, PURE_OBJECT_LIMIT, &revision) == PURE_LIMIT); + /* Rejected before reading the intentionally undersized insertion pointer. */ + CHECK(pure_store_splice(path, "work", latest, 0, 0, (PureSpan){insert, PURE_OBJECT_LIMIT}, PURE_OBJECT_LIMIT, &revision) == PURE_LIMIT); + CHECK(pure_store_splice(path, "work", latest, 0, 0, (PureSpan){insert, 1}, length, &revision) == PURE_LIMIT); + CHECK(revision == latest); + expect(path, "work", latest, (PureSpan){oracle, length}); + CHECK(pure_store_splice(path, "missing", 0, 1, 0, (PureSpan){0}, PURE_OBJECT_LIMIT, &revision) == PURE_LIMIT); + PureStoredView view; + CHECK(pure_store_get(path, "missing", 0, &view) == PURE_NOT_FOUND); + for (failed_stage = PURE_AFTER_HEADER; failed_stage <= PURE_BEFORE_RENAME; ++failed_stage) { + CHECK(pure_store_splice(path, "work", latest, 1, 3, (PureSpan){insert, 5}, PURE_OBJECT_LIMIT, &revision) == PURE_STORE_ERROR); + expect(path, "work", latest, (PureSpan){oracle, length}); + } + failed_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_store_splice(path, "work", latest, length, 0, (PureSpan){insert, 5}, PURE_OBJECT_LIMIT, &revision) == PURE_UNCERTAIN); + CHECK(revision == latest + 1); + failed_stage = -1; + memcpy(oracle + length, insert, 5); length += 5; + expect(path, "work", revision, (PureSpan){oracle, length}); + CHECK(pure_store_splice(path, "work", revision, 0, length, (PureSpan){0}, PURE_OBJECT_LIMIT, &revision) == PURE_OK); + expect(path, "work", revision, (PureSpan){0}); + char file[256]; + snprintf(file, sizeof(file), "%s/obj-work", path); + int fd = open(file, O_WRONLY); + const unsigned char maximum[] = {255,255,255,255,255,255,255,127}; + CHECK(fd >= 0 && pwrite(fd, maximum, 8, 8) == 8 && close(fd) == 0); + CHECK(pure_store_splice(path, "work", INT64_MAX, 0, 0, (PureSpan){0}, PURE_OBJECT_LIMIT, &revision) == PURE_LIMIT); +} + +static void runtime(const char *path) { + PureProgram program; + char error[160]; + CHECK(pure_program_load("examples/editor.pure", &program, error, sizeof(error))); + PureRuntime runtime; + PureWork slots[1]; + PureHost denied = pure_store_host(path); + denied.write = NULL; /* no write effect grants no splice permission */ + CHECK(pure_runtime_init(&runtime, slots, 1, &program, denied, 32)); + PureTicket ticket = {0}; + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 9, &ticket) == PURE_DENIED); + CHECK(!ticket.runtime && runtime.active == 0 && runtime.retained == 0); + CHECK(pure_runtime_restart(&runtime, &program, pure_store_host(path), 32)); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 9, &ticket) == PURE_OK); + CHECK(pure_runtime_cancel(ticket) && pure_runtime_release(ticket)); + CHECK(!pure_runtime_step(&runtime)); + PureStoredView view; + CHECK(pure_store_get(path, "native", 32, &view) == PURE_NOT_FOUND); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 7, &ticket) == PURE_OK); + memset(pure_runtime_ingress(ticket, NULL), 0, 7); + CHECK(pure_runtime_ready(ticket) && pure_runtime_step(&runtime)); + PureOutcome result; + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_LIMIT); + CHECK(pure_runtime_release(ticket) && runtime.commits == 0); + CHECK(pure_runtime_accept(&runtime, PURE_INPUT_WRITE, "native", 0, 9, &ticket) == PURE_OK); + unsigned char *input = pure_runtime_ingress(ticket, NULL); + memset(input, 0, 8); input[8] = 'x'; + CHECK(pure_runtime_ready(ticket) && pure_runtime_step(&runtime)); + CHECK(pure_runtime_result(ticket, &result) && result.status == PURE_OK && result.revision == 1); + CHECK(result.body.size == 1 && result.body.data == input + 8 && runtime.retained == 9); + expect(path, "native", 1, (PureSpan){(const unsigned char *)"x", 1}); + CHECK(pure_runtime_release(ticket) && runtime.retained == 0 && runtime.active == 0); +} + +int main(void) { + parser(); + char path[] = "/tmp/pure-splice-check-XXXXXX"; + CHECK(mkdtemp(path) && pure_store_init(path) == PURE_OK); + storage(path); + runtime(path); + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + CHECK(strncmp(entry->d_name, ".pending-", 9)); + CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + } + CHECK(closedir(entries) == 0 && rmdir(path) == 0); + puts("{\"splice_tests\":\"passed\",\"checks\":[\"authored-fields-and-atomic-parse-failure\"," + "\"binary-edit-oracle-and-old-snapshot\",\"bounds-conflict-size-overflow\"," + "\"publication-failures-and-uncertain-durability\",\"cancel-admission-and-borrowed-reply\"]}"); + return 0; +} diff --git a/prototypes/connections/tui-budget.json b/prototypes/connections/tui-budget.json new file mode 100644 index 0000000..1ca06b0 --- /dev/null +++ b/prototypes/connections/tui-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-fixed-document-terminal-editor", + "binaries": { + "pure-edit-tui-fixed": { + "max_text_bytes": 32768, + "max_data_bytes": 1152, + "max_bss_bytes": 64, + "needed": ["libc.so.6"] + } + }, + "scope": "Release-linked optional POSIX terminal editor for one build-time prepared document app, including the bounded document browser. The raised text ceiling reviews its measured 2,541-byte addition; default CLI and core ceilings are unchanged. Excludes libc, terminal emulator, kernel TTY buffers, heap and stack. Whole-process RSS, including 1,024-key browsing, is checked by check_tui.py on the recorded Linux AArch64 host." +} diff --git a/prototypes/connections/window-budget.json b/prototypes/connections/window-budget.json new file mode 100644 index 0000000..511557e --- /dev/null +++ b/prototypes/connections/window-budget.json @@ -0,0 +1,12 @@ +{ + "profile": "linux-aarch64-gcc14-selected-native-document-authoring", + "binaries": { + "pure-edit-window": { + "max_text_bytes": 57344, + "max_data_bytes": 1800, + "max_bss_bytes": 64, + "needed": ["libgtk-4.so.1", "libgio-2.0.so.0", "libgobject-2.0.so.0", "libglib-2.0.so.0", "libc.so.6"] + } + }, + "scope": "Review ceiling for the selected GTK document-and-definition host on Linux AArch64/GCC 14. Linked code sections and direct dependencies only; the measured 44 MiB whole-process RSS and shared GTK stack remain separate costs." +} diff --git a/prototypes/forgetful-core/README.md b/prototypes/forgetful-core/README.md new file mode 100644 index 0000000..fde22e3 --- /dev/null +++ b/prototypes/forgetful-core/README.md @@ -0,0 +1,116 @@ +# Pure: 기록을 버릴 수 있는 실행 코어 + +2026-09-22. **자체 실행 계약의 첫 C17 구현. 정확성과 자원 한도를 확인했으며, +독창성·성능 우위·제품 통합은 아직 검증하지 않았다.** 새 문법이나 화면 데모는 없다. + +후속: [기존 작성물의 표시 계산에 실행 계약을 이식](../../docs/research/009-bounded-display-integration.md)했다. +아래 정수 모델의 결과와 별도로, Zig/Wasm의 소수점·상태 변경 의미를 보존해 검증했다. + +## 우리가 확인한 질문 + +> 재사용을 위한 값과 변경 기록을 모두 버릴 수 있게 하면서, 잘못된 결과의 +> 재사용을 막고 한 요청이 사용할 작업량까지 제한할 수 있는가? + +기억이 부족할 때 정확도를 낮추는 대신 재계산한다. 재계산도 지정한 작업량을 +넘으면 결과를 발행하지 않고 `PF_WORK_LIMIT`를 반환한다. 필수 입력·프로그램은 +보존한다. 이 계약을 만족하면 재사용 기록을 필수 상태에서 분리하는 구현을 +계속 검토할 수 있다. 작은 모델에서도 오래된 값이 발행되거나 한도를 넘으면 +이 설계를 폐기·수정하는 것이 이번 작업의 중단 기준이었다. + +## 선행기술과 우리 구현의 경계 + +필요한 출력만 계산하는 것은 이미 [Adapton](https://matthewhammer.org/adapton/)의 +연구 대상이다. [Salsa의 튜닝 문서](https://salsa-rs.github.io/salsa/tuning.html)는 +LRU가 계산 결과를 제거하며, 질의 키·의존성 메타데이터는 그 제거 대상이 아니라고 +설명한다. 이것이 Salsa 전체에 다른 메모리 회수 기능이 없다는 뜻은 아니다. + +우리의 이번 구현 선택은 **값·입력 의존성 요약을 담은 캐시와 변경 기록을 모두 +고정 크기로 제한하고, 남은 증거로 재사용을 정당화할 수 없으면 버리는 것**이다. +새 이름을 붙인 증분 계산을 발명이라고 주장하지 않는다. 증분 계산, 변경 일지, +보수적인 무효화의 기존 아이디어를 이용한 자체 코어이며, 이 조합의 신규성이나 +기존 시스템 대비 개선은 입증하지 않았다. 소수 자료의 확인은 중복 검토의 +출발점이며 포괄적인 선행연구 검토가 아니다. 위 자료는 2026-09-22 확인했다. + +## 실행 규칙 + +- 프로그램은 최대 64개 노드의 DAG, 입력은 32개의 `uint64_t`다. 덧셈·곱셈은 + 2^64 모듈러 연산이다. 상수·입력·덧셈·곱셈·XOR·조건 선택을 지원한다. +- 조건 선택은 선택한 가지와 조건만 읽는다. 각 결과에 실제 읽은 입력의 + 32비트 요약을 합성한다. 자식의 캐시를 써도 그 의존성을 부모에 전한다. +- 캐시는 결과·입력 요약·확인한 revision을 함께 보관한다. 캐시 교체 시 + 이 세 가지를 함께 버리며, 별도의 질의별 키 테이블이나 역방향 간선을 남기지 않는다. +- 변경 일지는 고정 길이의 순환 배열이다. 기록이 덮어써지거나 삭제되면 + `history_floor`를 올린다. 그보다 오래된 캐시는 재사용할 근거가 없으므로 재계산한다. +- 기록이 충분하면 캐시의 입력 요약과 이후 변경이 겹치지 않을 때만 재사용한다. + 같은 값의 입력 쓰기는 revision을 올리지 않는다. +- 프로그램 교체는 전체 정의를 검증한 뒤 적용하며 입력을 보존하고 재사용 기록은 + 폐기한다. 잘못된 교체는 기존 프로그램·입력·기록을 유지한다. +- revision 오버플로 때는 이전 기록을 폐기한다. 입력 변경·조회·기록 삭제는 + 한 스레드에서 순서대로 호출한다. 동시 호출·외부 I/O·이벤트 재실행은 지원하지 않는다. + +정확성의 핵심은 다음이다. 기록이 완전한 구간에서 이전 실행이 읽었던 입력이 +하나도 변하지 않았다면, 순수하고 결정적인 계산은 같은 분기와 결과를 갖는다. +조건의 의존성도 포함해야 이 논리가 성립한다. 기록이 불완전하면 재사용하지 않는다. +테스트는 이 논증의 구현 오류를 찾는 수단이며 일반적인 형식 증명은 아니다. + +## 한도의 의미와 비용 + +`pf_eval`의 작업 한도에는 실행 단계·캐시 검사·변경 기록 검사가 포함된다. +성공할 때만 호출자의 출력에 값을 쓴다. 중단된 조회에서 이미 완성한 중간 캐시는 +남을 수 있지만 입력을 바꾸거나 부분 결과를 발행하지 않는다. 이 한도는 CPU 명령 +수나 실제 응답 시간 보장이 아니다. 재시도는 처음부터 시작하며 완료를 보장하지 않는다. + +현재 Linux AArch64/GCC 14.2.0에서 `sizeof(PfCore)`는 **3,696바이트**다. + +| 고정 영역 | 바이트 | +|---|---:| +| 프로그램 64개 노드 | 1,536 | +| 입력 32개 | 256 | +| 결과·의존성·revision 캐시 8개 | 192 | +| 변경 기록 8개 | 128 | +| 비재귀 평가 스택 64칸 | 1,536 | +| 개수·revision·정책 위치·작업 카운터 | 48 | + +코어는 힙 할당을 하지 않는다. 호출자가 제공하는 `PfCore`에 위 영역이 모두 들어간다. +논리적 캐시·기록 용량을 0~8로 조절해도 예약된 3,696바이트는 줄지 않는다. +메모리 압력에 맞춰 실제 저장 공간을 반납하는 할당기는 이번 구현에 없다. +노드 번호가 자식으로 내려갈 때 반드시 작아지므로 평가 스택은 노드 수를 넘지 않는다. + +이 수치는 전체 실행 RAM이 아니다. 실행 코드·C 호출 스택·호스트·출력과 로드할 +프로그램을 담은 호출자 버퍼는 별도다. GCC의 이 빌드에서 `pf_eval` 호출 프레임 +추정은 64바이트였다. 이는 전체 프로세스 스택 측정이나 다른 타깃의 보장이 아니다. +API는 초기화된 코어를 요구하고, 코어 필드의 외부 변경과 입력·출력 인수의 코어 내부 +별칭을 허용하지 않는다. `pf_load`에 넘긴 노드 배열도 코어와 겹치면 안 된다. + +## 확인한 것과 채택 보류 이유 + +[검증 기록](verification.json)의 일반 빌드와 ASan·UBSan 빌드가 각각 다음을 확인했다. + +- 성공 조회 **116,143개**가 독립적인 순방향 전체 계산과 일치했다. +- 작업량 중단 **16,425회**에서 부분 출력을 발행하지 않았다. +- 각 동작 뒤 결과를 조회하는 조건에서, 여섯 동작의 길이 5 이하 모든 순서와 + 아홉 캐시·기록 용량 조합을 확인했다. 동작은 두 입력 변경, 값 삭제, + 기록 삭제, 둘 다 삭제, 한도 0 조회다. +- 고정 seed의 32,000개 추가 동작에서 분기·입력·프로그램 교체·작업 한도를 섞었다. +- 기록 덮어쓰기, 미선택 가지 변경, 31번 입력 비트, revision 오버플로, + 잘못된 정의 거절, 최장 평가 스택을 별도로 확인했다. + +역시 중요한 결과는 **캐시가 없으면 작은 DAG도 재계산량이 지수적으로 커질 수 +있다**는 것이다. 그런 사례에서는 한도 내 거절을 확인했다. 따라서 ‘항상 적은 +메모리와 빠른 응답으로 완료’한다는 가설은 이 계약이 보장하지 않는다. + +이번으로 기본 계약 확인을 닫는다. 합성 조건을 늘리는 벤치마크는 만들지 않는다. +제품 채택은 실제 Pure 작업에서 작은 캐시의 반복 중단과 기록 검사 비용이 +허용되는지로 판단해야 한다. 현재 작업물의 32개 상태 제한과는 맞지만 큰 문서나 +서버의 임의 개수 입력에 적용할 표현은 없다. 메모리 반납·float 오류·상태 전이· +입출력의 의미도 기존 workbench와 통합되지 않았다. 기존 코어를 교체하지 않았다. + +## 재실행 + +```sh +python3 prototypes/forgetful-core/check.py --output /tmp/pure-forgetful-verification.json +``` + +C17 컴파일러, Python 3, ASan·UBSan이 필요하다. 외부 의존성을 내려받지 않는다. +출력 파일은 새 경로만 허용하며 소스 해시·컴파일러·플래그·호스트를 기록한다. +이 명령은 성능 비교를 실행하지 않는다. macOS 성능은 측정하지 않았다. diff --git a/prototypes/forgetful-core/check.py b/prototypes/forgetful-core/check.py new file mode 100644 index 0000000..d26adca --- /dev/null +++ b/prototypes/forgetful-core/check.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Verify the contract; this runner deliberately does not benchmark speed.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import shlex +import subprocess +import tempfile +from datetime import datetime, timezone + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, help='new JSON evidence file') + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('output already exists') + root = Path(__file__).resolve().parent + cc = shlex.split(os.environ.get('CC', 'cc')) + common = ['-std=c17', '-g', '-Wall', '-Wextra', '-Wpedantic', '-Werror'] + record = { + 'kind': 'correctness-and-bounds-only', + 'utc': datetime.now(timezone.utc).isoformat(), + 'host': platform.platform(), + 'machine': platform.machine(), + 'compiler': subprocess.check_output(cc + ['--version'], text=True).splitlines()[0], + 'source_sha256': { + name: hashlib.sha256((root / name).read_bytes()).hexdigest() + for name in ('core.h', 'core.c', 'test.c', 'check.py') + }, + 'runs': [], + } + with tempfile.TemporaryDirectory(prefix='pure-forgetful-') as temporary: + for name, extra in ( + ('native', ['-O2', '-fstack-usage']), + ('asan-ubsan', ['-O1', '-fsanitize=address,undefined', '-fno-omit-frame-pointer']), + ): + binary = Path(temporary) / name + flags = common + extra + subprocess.run(cc + flags + [str(root / 'core.c'), str(root / 'test.c'), + '-o', str(binary)], cwd=temporary, check=True) + completed = subprocess.run([str(binary)], text=True, capture_output=True) + if completed.returncode or completed.stderr: + raise RuntimeError(f'{name} failed ({completed.returncode}):\n' + f'{completed.stdout}\n{completed.stderr}') + result = json.loads(completed.stdout) + if record['runs'] and result != record['runs'][0]['result']: + raise RuntimeError('native and sanitized verification disagree') + run = {'name': name, 'flags': flags, 'result': result} + if name == 'native': + # Compiler estimates, not a measured process stack high-water mark. + run['compiler_stack_usage'] = { + path.name: path.read_text() + for path in sorted(Path(temporary).glob('*core.su')) + } + record['runs'].append(run) + output = json.dumps(record, ensure_ascii=False, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/forgetful-core/core.c b/prototypes/forgetful-core/core.c new file mode 100644 index 0000000..53edff0 --- /dev/null +++ b/prototypes/forgetful-core/core.c @@ -0,0 +1,163 @@ +#include "core.h" + +#include + +int pf_init(PfCore *core, uint32_t cache_capacity, uint32_t history_capacity) { + if (!core || cache_capacity > PF_CACHE || history_capacity > PF_HISTORY) + return PF_INVALID; + memset(core, 0, sizeof(*core)); + core->cache_capacity = cache_capacity; + core->history_capacity = history_capacity; + core->revision = 1; + core->history_floor = 1; + return PF_OK; +} + +void pf_forget(PfCore *core, unsigned what) { + if (what & PF_FORGET_VALUES) { + memset(core->memo, 0, sizeof(core->memo)); + core->next_memo = 0; + } + if (what & PF_FORGET_HISTORY) { + memset(core->history, 0, sizeof(core->history)); + core->next_change = 0; + /* Records older than this floor can no longer justify reuse. */ + core->history_floor = core->revision; + } +} + +int pf_load(PfCore *core, const PfNode *nodes, uint32_t count) { + if (!nodes || !count || count > PF_NODES) return PF_INVALID; + for (uint32_t i = 0; i < count; ++i) { + const PfNode n = nodes[i]; + if (n.op > PF_SELECT) return PF_INVALID; + if (n.op == PF_INPUT && n.a >= PF_INPUTS) return PF_INVALID; + if (n.op >= PF_ADD && (n.a >= i || n.b >= i)) return PF_INVALID; + if (n.op == PF_SELECT && n.c >= i) return PF_INVALID; + } + memcpy(core->nodes, nodes, count * sizeof(*nodes)); + core->node_count = count; + pf_forget(core, PF_FORGET_VALUES | PF_FORGET_HISTORY); + return PF_OK; +} + +int pf_write(PfCore *core, uint32_t input, uint64_t value) { + if (input >= PF_INPUTS) return PF_INVALID; + if (core->inputs[input] == value) return PF_OK; + /* No old certificate is allowed to become current after epoch wrap. */ + if (core->revision == UINT64_MAX) { + core->revision = 1; + pf_forget(core, PF_FORGET_VALUES | PF_FORGET_HISTORY); + } + ++core->revision; + core->inputs[input] = value; + if (!core->history_capacity) { + core->history_floor = core->revision; + } else { + PfChange *change = &core->history[core->next_change]; + if (change->revision > core->history_floor) + core->history_floor = change->revision; + *change = (PfChange){core->revision, UINT32_C(1) << input}; + core->next_change = (core->next_change + 1) % core->history_capacity; + } + return PF_OK; +} + +static int step(PfCore *core, uint64_t limit) { + if (core->work == limit) return 0; + ++core->work; + return 1; +} + +/* Return 1 for a proven hit, 0 for a miss, -1 for work exhaustion. No reverse + * dependency edges or version tables grow when nodes are queried. */ +static int lookup(PfCore *core, uint32_t node, uint64_t limit, PfMemo *found) { + for (uint32_t i = 0; i < core->cache_capacity; ++i) { + if (!step(core, limit)) return -1; + const PfMemo memo = core->memo[i]; + if (memo.node_plus_one != node + 1) continue; + if (memo.revision < core->history_floor) return 0; + if (memo.revision != core->revision) { + for (uint32_t j = 0; j < core->history_capacity; ++j) { + if (!step(core, limit)) return -1; + const PfChange change = core->history[j]; + if (change.revision > memo.revision && + (change.changed & memo.dependencies)) return 0; + } + } + *found = memo; + /* Validation can extend the certificate without evaluating the node. */ + core->memo[i].revision = core->revision; + return 1; + } + return 0; +} + +static void remember(PfCore *core, uint32_t node, uint64_t value, uint32_t deps) { + if (!core->cache_capacity) return; + /* Round-robin keeps replacement state fixed. Duplicates are safe: every + * entry has its own certificate; the first matching entry may cause extra + * recomputation but cannot authorize a stale value. */ + core->memo[core->next_memo] = (PfMemo){value, core->revision, deps, node + 1}; + core->next_memo = (core->next_memo + 1) % core->cache_capacity; +} + +static void push(PfCore *core, uint32_t node) { + core->frames[core->depth++] = (PfFrame){.node = node}; +} + +int pf_eval(PfCore *core, uint32_t node, uint64_t work_limit, uint64_t *out) { + core->work = 0; + core->depth = 0; + if (!out || node >= core->node_count) return PF_INVALID; + push(core, node); + uint64_t value = 0; + uint32_t deps = 0; + while (core->depth) { + if (!step(core, work_limit)) goto exhausted; + PfFrame *frame = &core->frames[core->depth - 1]; + const PfNode n = core->nodes[frame->node]; + if (frame->stage == 0) { + PfMemo memo; + const int hit = lookup(core, frame->node, work_limit, &memo); + if (hit < 0) goto exhausted; + if (hit) { + value = memo.value; + deps = memo.dependencies; + --core->depth; + continue; + } + if (n.op == PF_CONST) { + value = n.literal; + deps = 0; + } else if (n.op == PF_INPUT) { + value = core->inputs[n.a]; + deps = UINT32_C(1) << n.a; + } else { + frame->stage = 1; + push(core, n.a); + continue; + } + } else if (frame->stage == 1) { + frame->first = value; + frame->dependencies = deps; + frame->stage = 2; + push(core, n.op == PF_SELECT && !value ? n.c : n.b); + continue; + } else { + deps |= frame->dependencies; + if (n.op == PF_ADD) value = frame->first + value; + else if (n.op == PF_MUL) value = frame->first * value; + else if (n.op == PF_XOR) value = frame->first ^ value; + /* SELECT preserves the chosen result and includes control deps. */ + } + remember(core, frame->node, value, deps); + --core->depth; + } + *out = value; + return PF_OK; + +exhausted: + core->depth = 0; + return PF_WORK_LIMIT; +} diff --git a/prototypes/forgetful-core/core.h b/prototypes/forgetful-core/core.h new file mode 100644 index 0000000..061afc8 --- /dev/null +++ b/prototypes/forgetful-core/core.h @@ -0,0 +1,58 @@ +#ifndef PURE_FORGETFUL_CORE_H +#define PURE_FORGETFUL_CORE_H + +#include +#include + +/* An executable contract, not a replacement for the workbench VM. */ +enum { PF_NODES = 64, PF_INPUTS = 32, PF_CACHE = 8, PF_HISTORY = 8 }; +enum { PF_CONST, PF_INPUT, PF_ADD, PF_MUL, PF_XOR, PF_SELECT }; +enum { PF_OK, PF_INVALID, PF_WORK_LIMIT }; +enum { PF_FORGET_VALUES = 1, PF_FORGET_HISTORY = 2 }; + +typedef struct { + uint32_t op, a, b, c; + uint64_t literal; +} PfNode; + +typedef struct { + uint64_t value, revision; + uint32_t dependencies, node_plus_one; +} PfMemo; + +typedef struct { + uint64_t revision; + uint32_t changed; +} PfChange; + +typedef struct { + uint64_t first; + uint32_t node, stage, dependencies; +} PfFrame; + +/* All persistent arrays and the nonrecursive evaluation stack live here. + * Callers must treat these fields as private; public exposure allows static + * allocation and sizeof accounting without a heap or a platform allocator. */ +typedef struct { + PfNode nodes[PF_NODES]; + uint64_t inputs[PF_INPUTS]; + PfMemo memo[PF_CACHE]; + PfChange history[PF_HISTORY]; + PfFrame frames[PF_NODES]; + uint64_t revision, history_floor, work; + uint32_t node_count, cache_capacity, history_capacity; + uint32_t next_memo, next_change, depth; +} PfCore; + +int pf_init(PfCore *core, uint32_t cache_capacity, uint32_t history_capacity); +/* Load validates every operand before replacement. Inputs survive replacement; + * cached results and history do not. Caller supplies nonoverlapping nodes. */ +int pf_load(PfCore *core, const PfNode *nodes, uint32_t count); +int pf_write(PfCore *core, uint32_t input, uint64_t value); +void pf_forget(PfCore *core, unsigned what); +/* Queries are pure. Only PF_OK publishes *out. A failed query may leave valid + * intermediate memos, but never changes inputs or publishes a partial result. + * work counts evaluator stages, memo probes and history probes, not CPU time. */ +int pf_eval(PfCore *core, uint32_t node, uint64_t work_limit, uint64_t *out); + +#endif diff --git a/prototypes/forgetful-core/test.c b/prototypes/forgetful-core/test.c new file mode 100644 index 0000000..11d7024 --- /dev/null +++ b/prototypes/forgetful-core/test.c @@ -0,0 +1,256 @@ +#include "core.h" + +#include +#include +#include +#include + +static uint64_t checks, queries, exhausted_queries; +static uint64_t rng = UINT64_C(20260922); + +static uint64_t random_word(void) { + rng ^= rng << 13; + rng ^= rng >> 7; + rng ^= rng << 17; + return rng; +} + +/* Independent forward evaluator: no memo, history, stack machine, or demand + * scheduling. All nodes are total/pure, so eager SELECT arms are safe here. */ +static uint64_t reference(const PfNode *nodes, uint32_t count, + const uint64_t *inputs, uint32_t target) { + uint64_t values[PF_NODES] = {0}; + for (uint32_t i = 0; i < count; ++i) { + const PfNode n = nodes[i]; + switch (n.op) { + case PF_CONST: values[i] = n.literal; break; + case PF_INPUT: values[i] = inputs[n.a]; break; + case PF_ADD: values[i] = values[n.a] + values[n.b]; break; + case PF_MUL: values[i] = values[n.a] * values[n.b]; break; + case PF_XOR: values[i] = values[n.a] ^ values[n.b]; break; + case PF_SELECT: values[i] = values[n.a] ? values[n.b] : values[n.c]; break; + default: assert(0); + } + } + return values[target]; +} + +static void check_query(PfCore *core, const PfNode *nodes, uint32_t count, + const uint64_t *inputs, uint32_t target, uint64_t limit) { + const uint64_t sentinel = UINT64_C(0xbaadc0de12345678); + uint64_t out = sentinel; + const int status = pf_eval(core, target, limit, &out); + assert(core->work <= limit && core->depth == 0); + assert(memcmp(core->inputs, inputs, sizeof(core->inputs)) == 0); + assert(status == PF_OK || status == PF_WORK_LIMIT); + if (status == PF_OK) { + assert(out == reference(nodes, count, inputs, target)); + ++queries; + } else { + assert(out == sentinel); + ++exhausted_queries; + /* Completion is also checked after the interrupted query's memos. */ + assert(pf_eval(core, target, UINT64_C(10000000), &out) == PF_OK); + assert(out == reference(nodes, count, inputs, target)); + ++queries; + } + ++checks; +} + +static const PfNode branch_program[] = { + {.op = PF_INPUT, .a = 0}, + {.op = PF_INPUT, .a = 1}, + {.op = PF_CONST, .literal = 7}, + {.op = PF_ADD, .a = 1, .b = 2}, + {.op = PF_XOR, .a = 1, .b = 2}, + {.op = PF_SELECT, .a = 0, .b = 3, .c = 4}, + {.op = PF_MUL, .a = 5, .b = 3}, + {.op = PF_SELECT, .a = 0, .b = 6, .c = 4}, +}; + +static void walk(const PfCore *parent, const uint64_t *inputs, unsigned depth) { + for (unsigned action = 0; action < 6; ++action) { + PfCore core = *parent; + uint64_t next[PF_INPUTS]; + memcpy(next, inputs, sizeof(next)); + if (action < 2) { + next[action] ^= 1; + assert(pf_write(&core, action, next[action]) == PF_OK); + } else if (action < 5) { + pf_forget(&core, action - 1); + } else { + uint64_t untouched = 123; + assert(pf_eval(&core, 7, 0, &untouched) == PF_WORK_LIMIT); + assert(untouched == 123); + ++exhausted_queries; + } + check_query(&core, branch_program, 8, next, 7, 10000); + if (depth > 1) walk(&core, next, depth - 1); + } +} + +static void exhaustive(void) { + const uint32_t capacities[] = {0, 1, 8}; + for (unsigned c = 0; c < 3; ++c) { + for (unsigned h = 0; h < 3; ++h) { + PfCore core; + uint64_t inputs[PF_INPUTS] = {0}; + assert(pf_init(&core, capacities[c], capacities[h]) == PF_OK); + assert(pf_load(&core, branch_program, 8) == PF_OK); + walk(&core, inputs, 5); + } + } +} + +static void directed(void) { + PfCore core, saved; + uint64_t out, inputs[PF_INPUTS] = {0}; + assert(pf_init(&core, 8, 8) == PF_OK); + assert(pf_load(&core, branch_program, 8) == PF_OK); + saved = core; + assert(pf_init(&core, 9, 8) == PF_INVALID); + assert(memcmp(&core, &saved, sizeof(core)) == 0); + assert(pf_load(&core, NULL, 0) == PF_INVALID); + const PfNode invalid[] = {{.op = PF_ADD, .a = 0, .b = 0}}; + assert(pf_load(&core, invalid, 1) == PF_INVALID); + assert(memcmp(&core, &saved, sizeof(core)) == 0); + assert(pf_write(&core, 32, 1) == PF_INVALID); + assert(memcmp(&core, &saved, sizeof(core)) == 0); + out = 456; + assert(pf_eval(&core, 8, 100, &out) == PF_INVALID && out == 456); + + check_query(&core, branch_program, 8, inputs, 7, 10000); + /* An unrelated write still permits a proven hit; forgetting its history + * must make the old memo unprovable even though its value happens to match. */ + inputs[31] = 99; + assert(pf_write(&core, 31, 99) == PF_OK); + saved = core; + assert(pf_write(&core, 31, 99) == PF_OK); + assert(memcmp(&core, &saved, sizeof(core)) == 0); + check_query(&core, branch_program, 8, inputs, 7, 10000); + assert(core.work <= 1 + PF_CACHE + PF_HISTORY); + inputs[31] = 100; + assert(pf_write(&core, 31, 100) == PF_OK); + pf_forget(&core, PF_FORGET_HISTORY); + check_query(&core, branch_program, 8, inputs, 7, 10000); + const uint64_t recompute_work = core.work; + check_query(&core, branch_program, 8, inputs, 7, 10000); + assert(core.work < recompute_work); + inputs[0] = 1; + assert(pf_write(&core, 0, 1) == PF_OK); + check_query(&core, branch_program, 8, inputs, 7, 10000); + + /* Overwrite the journal many times without querying. */ + for (unsigned i = 0; i < 40; ++i) { + inputs[1] = i; + assert(pf_write(&core, 1, i) == PF_OK); + } + check_query(&core, branch_program, 8, inputs, 7, 10000); + /* White-box setup of the otherwise impractical epoch rollover case. */ + core.revision = UINT64_MAX; + inputs[1] = UINT64_MAX; + assert(pf_write(&core, 1, inputs[1]) == PF_OK); + assert(core.revision == 2); + check_query(&core, branch_program, 8, inputs, 7, 10000); + for (uint64_t budget = 0; budget < 160; ++budget) { + pf_forget(&core, PF_FORGET_VALUES | PF_FORGET_HISTORY); + check_query(&core, branch_program, 8, inputs, 7, budget); + } + + const PfNode select[] = { + {.op = PF_INPUT, .a = 0}, {.op = PF_INPUT, .a = 1}, + {.op = PF_INPUT, .a = 31}, {.op = PF_SELECT, .a = 0, .b = 1, .c = 2}, + }; + assert(pf_load(&core, select, 4) == PF_OK); + inputs[0] = 0; + assert(pf_write(&core, 0, 0) == PF_OK); + check_query(&core, select, 4, inputs, 3, 10000); + int saw_dependencies = 0; + for (unsigned i = 0; i < PF_CACHE; ++i) { + if (core.memo[i].node_plus_one == 4) { + assert(core.memo[i].dependencies == UINT32_C(0x80000001)); + saw_dependencies = 1; + } + } + assert(saw_dependencies); + inputs[1] = 123; + assert(pf_write(&core, 1, 123) == PF_OK); + check_query(&core, select, 4, inputs, 3, 10000); + assert(core.work <= 1 + PF_CACHE + PF_HISTORY); + inputs[31] = 456; + assert(pf_write(&core, 31, 456) == PF_OK); + check_query(&core, select, 4, inputs, 3, 10000); + inputs[0] = 1; + assert(pf_write(&core, 0, 1) == PF_OK); + check_query(&core, select, 4, inputs, 3, 10000); + + PfNode chain[PF_NODES] = {{.op = PF_CONST, .literal = UINT64_MAX}}; + for (uint32_t i = 1; i < PF_NODES; ++i) + chain[i] = (PfNode){.op = PF_ADD, .a = i - 1, .b = 0}; + assert(pf_load(&core, chain, PF_NODES) == PF_OK); + check_query(&core, chain, PF_NODES, inputs, PF_NODES - 1, 10000); + chain[0].literal = 2; + assert(pf_load(&core, chain, PF_NODES) == PF_OK); + check_query(&core, chain, PF_NODES, inputs, PF_NODES - 1, 10000); + + /* A compact DAG can require exponential work without retained results. + * The core must reject within the budget, not promise bounded latency. */ + assert(pf_init(&core, 0, 0) == PF_OK); + for (uint32_t i = 1; i < PF_NODES; ++i) chain[i].b = i - 1; + assert(pf_load(&core, chain, PF_NODES) == PF_OK); + out = 456; + assert(pf_eval(&core, PF_NODES - 1, 100, &out) == PF_WORK_LIMIT); + assert(core.work == 100 && out == 456 && core.depth == 0); + ++exhausted_queries; +} + +static void random_trace(void) { + for (unsigned trial = 0; trial < 32; ++trial) { + PfCore core; + PfNode nodes[16] = {0}; + uint64_t inputs[PF_INPUTS] = {0}; + assert(pf_init(&core, trial % 9, (trial / 3) % 9) == PF_OK); + for (uint32_t i = 0; i < 16; ++i) { + nodes[i].op = i < 4 ? PF_INPUT : (uint32_t)(random_word() % 6); + if (nodes[i].op == PF_INPUT) nodes[i].a = (uint32_t)(random_word() % 32); + else if (nodes[i].op >= PF_ADD) { + nodes[i].a = (uint32_t)(random_word() % i); + nodes[i].b = (uint32_t)(random_word() % i); + nodes[i].c = (uint32_t)(random_word() % i); + } + nodes[i].literal = random_word(); + } + assert(pf_load(&core, nodes, 16) == PF_OK); + for (unsigned action = 0; action < 1000; ++action) { + const uint64_t choice = random_word(); + if (choice % 11 < 6) { + const uint32_t id = (uint32_t)(random_word() % 32); + inputs[id] = random_word(); + assert(pf_write(&core, id, inputs[id]) == PF_OK); + } else if (choice % 11 < 9) { + pf_forget(&core, (unsigned)(choice % 3 + 1)); + } else if (choice % 11 == 9) { + const uint32_t id = 4 + (uint32_t)(random_word() % 12); + nodes[id] = (PfNode){.op = PF_SELECT, + .a = (uint32_t)(random_word() % id), + .b = (uint32_t)(random_word() % id), + .c = (uint32_t)(random_word() % id)}; + assert(pf_load(&core, nodes, 16) == PF_OK); + } + check_query(&core, nodes, 16, inputs, (uint32_t)(choice % 16), choice % 300); + } + } +} + +int main(void) { + directed(); + exhaustive(); + random_trace(); + printf("{\"checks\":%" PRIu64 ",\"successful_queries\":%" PRIu64 + ",\"exhausted_queries\":%" PRIu64 ",\"core_bytes\":%zu," + "\"node_bytes\":%zu,\"memo_bytes\":%zu,\"history_entry_bytes\":%zu," + "\"frame_bytes\":%zu,\"seed\":20260922}\n", + checks, queries, exhausted_queries, sizeof(PfCore), sizeof(PfNode), + sizeof(PfMemo), sizeof(PfChange), sizeof(PfFrame)); + return 0; +} diff --git a/prototypes/forgetful-core/verification.json b/prototypes/forgetful-core/verification.json new file mode 100644 index 0000000..bf5e37a --- /dev/null +++ b/prototypes/forgetful-core/verification.json @@ -0,0 +1,67 @@ +{ + "kind": "correctness-and-bounds-only", + "utc": "2026-09-22T13:34:34.908273+00:00", + "host": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "source_sha256": { + "core.h": "cbc8cad736c72bf45f1e7d9d545b7ac40af44c4e79c9f7128ff15bc3de79e8f5", + "core.c": "6b98b56ab6d0802135d2b40877a3020224af687722d934911ba360bd9ba7f00e", + "test.c": "3d87a842ec15f9c3e35e7238165bc29652573bbe731fde348dd4500879bf7171", + "check.py": "2013f0530fbd06b19672da4ea4660cb44f00bf6a2154b32a3e8375b5befb2b71" + }, + "runs": [ + { + "name": "native", + "flags": [ + "-std=c17", + "-g", + "-Wall", + "-Wextra", + "-Wpedantic", + "-Werror", + "-O2", + "-fstack-usage" + ], + "result": { + "checks": 116143, + "successful_queries": 116143, + "exhausted_queries": 16425, + "core_bytes": 3696, + "node_bytes": 24, + "memo_bytes": 24, + "history_entry_bytes": 16, + "frame_bytes": 24, + "seed": 20260922 + }, + "compiler_stack_usage": { + "native-core.su": "/home/eunhhu/work/pure/prototypes/forgetful-core/core.c:5:5:pf_init\t48\tstatic\n/home/eunhhu/work/pure/prototypes/forgetful-core/core.c:16:6:pf_forget\t0\tstatic\n/home/eunhhu/work/pure/prototypes/forgetful-core/core.c:29:5:pf_load\t32\tstatic\n/home/eunhhu/work/pure/prototypes/forgetful-core/core.c:44:5:pf_write\t0\tstatic\n/home/eunhhu/work/pure/prototypes/forgetful-core/core.c:109:5:pf_eval\t64\tstatic\n" + } + }, + { + "name": "asan-ubsan", + "flags": [ + "-std=c17", + "-g", + "-Wall", + "-Wextra", + "-Wpedantic", + "-Werror", + "-O1", + "-fsanitize=address,undefined", + "-fno-omit-frame-pointer" + ], + "result": { + "checks": 116143, + "successful_queries": 116143, + "exhausted_queries": 16425, + "core_bytes": 3696, + "node_bytes": 24, + "memo_bytes": 24, + "history_entry_bytes": 16, + "frame_bytes": 24, + "seed": 20260922 + } + } + ] +} diff --git a/prototypes/live-rules/.gitignore b/prototypes/live-rules/.gitignore new file mode 100644 index 0000000..84c048a --- /dev/null +++ b/prototypes/live-rules/.gitignore @@ -0,0 +1 @@ +/build/ diff --git a/prototypes/live-rules/README.md b/prototypes/live-rules/README.md new file mode 100644 index 0000000..a32d697 --- /dev/null +++ b/prototypes/live-rules/README.md @@ -0,0 +1,126 @@ +# Pure · 실행 중 규칙 바꾸기 + +공룡 연습 장면에서 점프 규칙을 바꾸고, 현재 상태와 입력이 허용·거절된 이유를 +확인하는 첫 사용 경험 실험이다. 부딪혀도 계속 진행되며, 규칙을 적용해도 위치·속도· +진행 상태는 유지된다. **처음부터**만 규칙과 상태를 함께 초기화한다. + +Zig 코어 하나를 WebAssembly로 빌드해 브라우저와 터미널에서 사용한다. +JavaScript에는 게임 물리나 별도의 규칙 실행기가 없다. 이 데모의 목적은 편집 의미와 +관찰 비용을 확인하는 것이다. 일반 런타임·전체 앱 편집기나 성능 우월성을 입증한 결과는 아니다. + +## 실행 + +저장소 루트에서 다음을 실행한다. 외부 JavaScript 패키지는 필요하지 않다. + +```sh +python3 prototypes/live-rules/manage.py serve --port 8765 +``` + +브라우저에서 를 연다. 포함된 `web/core.wasm`을 사용한다. +`file://`로 HTML을 직접 여는 대신 위 서버를 사용한다. 서버는 로컬 주소에만 바인딩한다. + +소스 수정 후에는 [Zig 0.15.2](https://ziglang.org/download/)로 다시 빌드한다. +새 버전과 구분해 재현하기 위해 이 버전으로 고정했다. 전체 재빌드에는 인터넷이 필요하지 않다. + +```sh +python3 prototypes/live-rules/manage.py build +# PATH에 없는 컴파일러를 지정할 때: +python3 prototypes/live-rules/manage.py build --zig /path/to/zig +``` + +빌드는 네이티브 테스트를 먼저 수행한다. 그 뒤 `wasm32-freestanding`, `ReleaseSafe`, +스택 예약 16 KiB, 초기·최대 선형 메모리 64 KiB로 빌드한다. 컴파일 캐시는 `build/`에 둔다. +버전·소스 해시·바이너리 해시·검사 출력은 `build/build.json`에 저장한다. +Wasm 파일 크기와 선형 메모리 용량은 서로 다른 값이다. + +## 직접 확인할 작업 + +1. 시작하고 **점프**를 누른다. 공중에서 다시 누르면 왜 거절됐는지 확인한다. +2. 공중에서 일시정지한다. 점프 조건을 **두 번 점프**로 바꾸고 **규칙 적용**을 누른다. +3. 위치와 진행 상태가 그대로인지 보고 다시 점프한다. 새 규칙의 판정이 표시되는지 확인한다. +4. 점프를 비활성화해 거절 이유를 확인한 뒤 다시 활성화한다. + +시간을 놓치면 정지 상태에서 점프하고 **한 프레임**씩 진행해도 된다. +정지 중 입력도 판정하지만, 실제 위치 이동은 프레임을 진행할 때 일어난다. +규칙을 바꾸기 전의 마지막 판정은 해당 규칙 버전의 기록으로 남는다. +입력과 규칙 변경은 한 프레임 처리 사이에서 순서대로 적용한다. + +## 같은 코어를 터미널에서 사용 + +Node.js 20 이상으로 실행한다. 검증 환경은 Node 24다. + +```sh +node prototypes/live-rules/cli.mjs +node prototypes/live-rules/cli.mjs --demo +node prototypes/live-rules/cli.mjs --demo --json +``` + +터미널에서는 시간이 저절로 흐르지 않는다. 아래 명령을 순서대로 입력하면 +공중에서의 거절과 규칙 수정 후 두 번째 점프를 정확히 같은 프레임에서 관찰할 수 있다. + +```text +jump +step 8 +jump +rules double 12 on +jump +inspect +memory +``` + +`help`는 명령 안내, `quit`은 종료다. 규칙 편집은 세 값을 한꺼번에 검증해 적용한다. +지원하지 않는 값이나 잘못된 명령은 이전 규칙과 상태를 보존한 채 거절한다. + +## 구조와 측정 범위 + +| 파일 | 역할 | +|---|---| +| `core.zig` | 실제 여섯 노드 판정 그래프, 고정 간격 물리, 규칙 변경, 상태·판정·기록 보관, 네이티브 테스트 | +| `web/` | 같은 코어에 연결하는 화면·키보드·버튼 어댑터 | +| `runtime.mjs`, `cli.mjs` | 같은 Wasm을 로드하는 터미널 어댑터 | +| `adapter.test.mjs` | 실제 Wasm과 입력 어댑터의 의미·메모리 한도 검증 | +| `measure.mjs` | 자료 구조 크기, 정확한 변경 기록, 소스·바이너리 해시 저장 | + +규칙은 점프 조건·힘·활성화 세 항목이다. 입력·활성 여부·접지 여부·남은 점프 수를 +읽고, 조건을 선택한 뒤 AND로 합류하는 실제 고정 그래프를 평가한다. +연산이나 연결을 자유롭게 추가하는 그래프 편집기는 아직 없다. + +자료 구조 크기는 Zig의 `@sizeOf`로 계산한다. 그래프 상수는 컴파일러가 코드로 +접을 수도 있으므로 이 합계를 물리 메모리 실측으로 해석하지 않는다. +Wasm 선형 메모리 64 KiB에는 코어 데이터와 스택 여유가 포함되며, 둘을 더하지 않는다. +브라우저/Node 엔진, DOM·렌더러, 코드와 프로세스 메모리는 별도이며 이번 수치에 포함하지 않는다. + +가장 최근 입력의 판정과 최근 사건 16개만 보관한다. 오래된 사건을 덮어쓰는 +이 기록은 완전한 실행 재생 로그가 아니다. 프레임 입력 큐를 만들어 몰래 버리는 방식도 아니다. +브라우저가 숨겨지면 일시정지하고, 프레임 처리가 밀린 경우의 시간 제한은 화면에 표시한다. +이 데모는 하드 실시간 동작을 보장하지 않는다. + +## 검증 및 기록 생성 + +```sh +python3 prototypes/live-rules/manage.py build +node prototypes/live-rules/adapter.test.mjs +node prototypes/live-rules/measure.mjs prototypes/live-rules/results/my-run +``` + +측정 출력 폴더의 상위 `results/`는 있어야 하며, 이미 있는 출력 폴더를 덮어쓰지 않는다. +측정 스크립트는 처리 속도·입력 지연을 측정하지 않는다. 브라우저를 통한 실제 조작 확인은 +이 자동 검사와 별도로 수행한다. 자세한 결과는 [연구 보고서 003](../../docs/research/003-live-rules.md)에 있다. + +브라우저 검증용 `qa-browser.js`는 Playwright CLI의 `run-code`에 전달하는 함수 표현식이다. +서버를 실행하고 CLI로 해당 페이지를 연 뒤 다음처럼 파일 내용을 인수로 전달할 수 있다. +검증에 사용한 CLI 버전은 0.1.21, 브라우저는 Chromium 151이며 런타임의 의존성은 아니다. +Playwright가 사용하는 브라우저의 설치 또는 실행 경로 설정은 별도로 필요하다. + +```sh +npx @playwright/cli -s=pure-live open http://127.0.0.1:8765 +python3 - <<'PY' +from pathlib import Path +import subprocess +Path('output/playwright').mkdir(parents=True, exist_ok=True) +script = Path('prototypes/live-rules/qa-browser.js').read_text() +subprocess.run(['npx', '@playwright/cli', '-s=pure-live', 'run-code', script], check=True) +PY +``` + +이 검사는 화면과 상태를 초기화하고 여러 시나리오를 조작한다. 개발용 페이지에서 실행한다. diff --git a/prototypes/live-rules/adapter.test.mjs b/prototypes/live-rules/adapter.test.mjs new file mode 100644 index 0000000..95af13b --- /dev/null +++ b/prototypes/live-rules/adapter.test.mjs @@ -0,0 +1,96 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadCore, snapshot, storage, parseCommand, execute } from './runtime.mjs'; + +test('the actual Wasm preserves airborne state through a rule change, then permits the second jump', async () => { + const c = await loadCore(); + execute(c, parseCommand('jump')); + execute(c, parseCommand('step 8')); + execute(c, parseCommand('jump')); + const before = snapshot(c); + assert.equal(before.state.lastReason, 3); + const after = execute(c, parseCommand('rules double 12 on')); + for (const field of ['tick', 'y', 'vy', 'distance', 'obstacleX', 'jumpsUsed', 'hits', 'passed']) { + assert.equal(after.state[field], before.state[field], field); + } + assert.deepEqual(after.decision, before.decision); + assert.equal(after.state.revision, 1); + execute(c, parseCommand('jump')); + assert.equal(c.get(10), 1); + assert.equal(c.get(2), 1200); + assert.equal(c.get(7), 2); + assert.equal(c.input(2), 0); + assert.equal(c.get(10), 4); +}); + +test('bad CLI edits and malformed numeric input cannot silently coerce into a different rule', async () => { + const c = await loadCore(); + const before = snapshot(c); + for (const command of ['rules double 8.5 on', 'rules double 4294967306 on', 'rules ground 5 on', + 'rules other 10 on', 'rules double 10 maybe', 'rules double 10 on extra', 'step 0', 'step 601', 'jump extra']) { + assert.throws(() => execute(c, parseCommand(command)), undefined, command); + assert.deepEqual(snapshot(c), before); + } + assert.equal(c.apply_rules(2, 10, 1), 1); + assert.deepEqual(snapshot(c), before); +}); + +test('keyboard, pointer and terminal requests give the same simulation result', async () => { + const states = []; + for (const source of [0, 1, 2]) { + const c = await loadCore(); + c.apply_rules(1, 12, 1); + c.input(source); + for (let i = 0; i < 8; i++) c.step(); + c.input(source); + const { lastSource, ...state } = snapshot(c).state; + assert.equal(lastSource, source); + states.push(state); + } + assert.deepEqual(states[0], states[1]); + assert.deepEqual(states[1], states[2]); +}); + +test('paused input has an inspectable decision; advance cannot move paused state', async () => { + const c = await loadCore(); + assert.equal(snapshot(c).decision, null); + assert.equal(c.input(0), 1); + assert.equal(c.get(1), 0); + assert.equal(c.get(17), 0); + const before = snapshot(c); + assert.equal(c.advance(60), 0); + assert.deepEqual(snapshot(c), before); + c.step(); + assert.equal(c.get(1), 945); + assert.equal(c.get(0), 1); +}); + +test('bounded history and fixed linear memory survive repeated play and edits', async () => { + const c = await loadCore(); + const before = storage(c); + c.set_running(1); + for (let i = 0; i < 5000; i++) { + c.apply_rules(i % 2, 6 + i % 11, 1); + c.input(i % 3); + c.advance(8); + } + assert.equal(c.history_count(), 16); + assert.equal(c.history_get(16, 0), -1); + assert.equal(c.get(0), 40000); + assert.deepEqual(storage(c), before); + assert.equal(before.wasmLinearMemoryBytes, 65536); + assert.throws(() => c.memory.grow(1), RangeError); + const b = before.layoutBytes; + assert.equal(b.logicalTotal, b.engine + b.graph); + assert.ok(b.engine >= b.rules + b.state + b.decision + b.history); +}); + +test('replaying the same CLI operations produces identical snapshots and journal', async () => { + const trace = ['jump', 'step 8', 'jump', 'rules double 10 on', 'jump', 'step 60', 'rules ground 8 off', 'jump']; + const snapshots = []; + for (let i = 0; i < 2; i++) { + const c = await loadCore(); + snapshots.push(trace.map((line) => execute(c, parseCommand(line)))); + } + assert.deepEqual(snapshots[0], snapshots[1]); +}); diff --git a/prototypes/live-rules/cli.mjs b/prototypes/live-rules/cli.mjs new file mode 100644 index 0000000..2c8a564 --- /dev/null +++ b/prototypes/live-rules/cli.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { createInterface } from 'node:readline'; +import { loadCore, storage, parseCommand, execute } from './runtime.mjs'; + +const help = `Pure · 규칙 실험실 (브라우저와 같은 Zig 코어) + jump 점프 입력 + step [1..600] 지정한 프레임만 진행 (기본 1) + rules ground|double 6..16 on|off + 점프 조건·힘·활성화를 함께 적용 + status / inspect / memory 현재 상태 / 마지막 판정·기록 / 공간 + reset 규칙과 상태 모두 처음으로 + help / quit +예: jump → step 8 → jump → rules double 10 on → jump +시간은 step으로만 진행합니다. 실행 규칙 변경은 진행 상태를 보존합니다.`; + +const reason = ['아직 입력 없음', '점프 허용', '점프 비활성', '공중: 땅에서만 가능', '두 번 모두 사용', '지원하지 않는 입력']; + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help')) { console.log(help); return; } + if (args.some((x) => !['--demo', '--json'].includes(x))) throw new Error('사용법: node cli.mjs [--demo] [--json]'); + const json = args.includes('--json'); + const core = await loadCore(); + function show(line) { + const command = parseCommand(line); + if (command.op === 'help') { console.log(help); return true; } + if (command.op === 'quit') return false; + const result = execute(core, command); + if (json) { + console.log(JSON.stringify({ command: line, ...result, ...(command.op === 'memory' ? { storage: storage(core) } : {}) })); + } else if (command.op === 'memory') { + console.log(JSON.stringify(storage(core), null, 2)); + } else if (command.op === 'inspect') { + console.log(JSON.stringify(result, null, 2)); + } else { + const s = result.state; + console.log(`t=${s.tick} · 높이 ${(s.y / 100).toFixed(2)} · 속도 ${(s.vy / 100).toFixed(2)} · 점프 ${s.jumpsUsed}/2 · 규칙 r${s.revision} (${s.mode ? 'double' : 'ground'}, ${s.impulse}, ${s.enabled ? 'on' : 'off'}) · ${reason[s.lastReason]}`); + } + return true; + } + if (args.includes('--demo')) { + for (const line of ['status', 'jump', 'step 8', 'jump', 'rules double 10 on', 'jump', 'step 60', 'memory']) show(line); + return; + } + if (!json && process.stdin.isTTY) console.log(help); + const terminal = createInterface({ input: process.stdin, output: process.stdout, + terminal: Boolean(process.stdin.isTTY) }); + terminal.setPrompt('pure> '); + if (process.stdin.isTTY) terminal.prompt(); + for await (const line of terminal) { + try { + if (!show(line)) { terminal.close(); break; } + } catch (error) { + console.error(error.message); + if (!process.stdin.isTTY) process.exitCode = 1; + } + if (process.stdin.isTTY) terminal.prompt(); + } +} + +main().catch((error) => { + console.error(`${error.message}\n빌드: python3 prototypes/live-rules/manage.py build`); + process.exitCode = 1; +}); diff --git a/prototypes/live-rules/core.zig b/prototypes/live-rules/core.zig new file mode 100644 index 0000000..bd709df --- /dev/null +++ b/prototypes/live-rules/core.zig @@ -0,0 +1,504 @@ +//! A bounded, deterministic practice-game experiment, not a general runtime. +//! The same source is tested natively and compiled to freestanding WebAssembly. +//! All persistent storage is static; functions perform no allocation. + +const std = @import("std"); + +const Rules = struct { + mode: u32 = 0, + impulse: u32 = 10, + enabled: u32 = 1, + revision: u32 = 0, +}; + +const State = struct { + tick: u32 = 0, + y: i32 = 0, + vy: i32 = 0, + distance: u32 = 0, + obstacle_x: i32 = 800, + passed: u32 = 0, + hits: u32 = 0, + jumps_used: u32 = 0, + running: u32 = 0, + last_reason: u32 = 0, + last_source: u32 = std.math.maxInt(u32), + total_inputs: u32 = 0, + accepted_jumps: u32 = 0, + obstacle_height: u32 = 48, + rng: u32 = 0x50555245, + obstacle_touched: u32 = 0, + obstacle_passed: u32 = 0, +}; + +const Decision = struct { + tick: u32 = 0, + revision: u32 = 0, + y: i32 = 0, + jumps: u32 = 0, + values: [6]u8 = @splat(0), + valid: bool = false, +}; + +const Event = struct { + tick: u32 = 0, + kind: u32 = 0, + detail: u32 = 0, + revision: u32 = 0, +}; + +const history_capacity: u32 = 16; + +const History = struct { + entries: [history_capacity]Event = @splat(.{}), + next: u32 = 0, + count: u32 = 0, +}; + +const Engine = struct { + rules: Rules = .{}, + state: State = .{}, + decision: Decision = .{}, + history: History = .{}, +}; + +const Op = enum(u8) { request, enabled, grounded, extra, select, all }; +const Node = struct { op: Op, a: u8 = 0, b: u8 = 0, c: u8 = 0 }; + +// Node indices are topologically ordered. These descriptors drive evaluation; +// the display is observing this actual graph, not a separate diagram model. +const graph = [_]Node{ + .{ .op = .request }, + .{ .op = .enabled }, + .{ .op = .grounded }, + .{ .op = .extra }, + .{ .op = .select, .a = 2, .b = 3 }, + .{ .op = .all, .a = 0, .b = 1, .c = 4 }, +}; + +var engine: Engine = .{}; + +fn asPublic(value: u32) i32 { + return @bitCast(value); +} + +fn grounded() bool { + // A jump accepted while paused already consumes the ground jump even though + // position will not be integrated until the next tick. + return engine.state.y == 0 and engine.state.vy == 0; +} + +fn record(kind: u32, detail: u32) void { + const h = &engine.history; + h.entries[h.next] = .{ + .tick = engine.state.tick, + .kind = kind, + .detail = detail, + .revision = engine.rules.revision, + }; + h.next = (h.next + 1) % history_capacity; + h.count = @min(h.count + 1, history_capacity); +} + +fn evaluate(source: u32) void { + var decision: Decision = .{ + .tick = engine.state.tick, + .revision = engine.rules.revision, + .y = engine.state.y, + .jumps = engine.state.jumps_used, + .valid = true, + }; + for (graph, 0..) |node, i| { + decision.values[i] = switch (node.op) { + .request => @intFromBool(source <= 2), + .enabled => @intFromBool(engine.rules.enabled == 1), + .grounded => @intFromBool(grounded()), + .extra => @intFromBool(engine.state.jumps_used < 2), + .select => if (engine.rules.mode == 0) decision.values[node.a] else decision.values[node.b], + .all => decision.values[node.a] & decision.values[node.b] & decision.values[node.c], + }; + } + engine.decision = decision; +} + +export fn reset() void { + engine = .{}; +} + +export fn set_running(flag: u32) void { + engine.state.running = @intFromBool(flag != 0); +} + +export fn apply_rules(mode: u32, impulse: u32, enabled: u32) u32 { + // Validate the complete candidate before mutating any part of the engine. + if (mode > 1 or impulse < 6 or impulse > 16 or enabled > 1) return 1; + const revision = engine.rules.revision +% 1; + engine.rules = .{ .mode = mode, .impulse = impulse, .enabled = enabled, .revision = revision }; + record(3, mode); + return 0; +} + +export fn input(source: u32) u32 { + evaluate(source); + const state = &engine.state; + state.total_inputs +%= 1; + state.last_source = source; + if (engine.decision.values[5] != 0) { + state.last_reason = 1; + state.vy = @as(i32, @intCast(engine.rules.impulse)) * 100; + state.jumps_used +%= 1; + state.accepted_jumps +%= 1; + record(1, source); + return 1; + } + state.last_reason = if (source > 2) 5 else if (engine.rules.enabled == 0) 2 else if (engine.rules.mode == 0) 3 else 4; + record(2, state.last_reason); + return 0; +} + +fn nextRandom() u32 { + engine.state.rng = engine.state.rng *% 1664525 +% 1013904223; + return engine.state.rng; +} + +fn tickOnce() void { + const state = &engine.state; + state.tick +%= 1; + state.distance +%= 4; + + if (!grounded()) { + state.vy -= 55; + state.y += state.vy; + if (state.y <= 0) { + state.y = 0; + state.vy = 0; + state.jumps_used = 0; + record(4, 0); + } + } + + state.obstacle_x -= 4; + const horizontal_overlap = state.obstacle_x < 156 and state.obstacle_x + 32 > 120; + if (horizontal_overlap and state.y < @as(i32, @intCast(state.obstacle_height)) * 100 and state.obstacle_touched == 0) { + state.hits +%= 1; + state.obstacle_touched = 1; + record(5, state.hits); + } + if (state.obstacle_x + 32 <= 120 and state.obstacle_passed == 0) { + state.passed +%= 1; + state.obstacle_passed = 1; + record(6, state.passed); + } + if (state.obstacle_x + 32 < 0) { + state.obstacle_x = 500 + @as(i32, @intCast(nextRandom() % 301)); + state.obstacle_height = 32 + (nextRandom() % 4) * 8; + state.obstacle_touched = 0; + state.obstacle_passed = 0; + } +} + +export fn advance(ticks: u32) u32 { + if (ticks > 600 or engine.state.running == 0) return 0; + for (0..ticks) |_| tickOnce(); + return ticks; +} + +export fn step() void { + tickOnce(); +} + +export fn get(field: u32) i32 { + const s = &engine.state; + return switch (field) { + 0 => asPublic(s.tick), + 1 => s.y, + 2 => s.vy, + 3 => asPublic(s.distance), + 4 => s.obstacle_x, + 5 => asPublic(s.passed), + 6 => asPublic(s.hits), + 7 => asPublic(s.jumps_used), + 8 => asPublic(s.running), + 9 => asPublic(engine.rules.revision), + 10 => asPublic(s.last_reason), + 11 => asPublic(s.last_source), + 12 => asPublic(s.total_inputs), + 13 => asPublic(s.accepted_jumps), + 14 => asPublic(engine.rules.mode), + 15 => asPublic(engine.rules.impulse), + 16 => asPublic(engine.rules.enabled), + 17 => @intFromBool(grounded()), + 18 => asPublic(s.obstacle_height), + else => -1, + }; +} + +export fn node_value(id: u32) i32 { + if (!engine.decision.valid or id >= engine.decision.values.len) return -1; + return engine.decision.values[id]; +} + +export fn decision_meta(field: u32) i32 { + if (!engine.decision.valid) return -1; + return switch (field) { + 0 => asPublic(engine.decision.tick), + 1 => asPublic(engine.decision.revision), + 2 => engine.decision.y, + 3 => asPublic(engine.decision.jumps), + else => -1, + }; +} + +export fn history_count() u32 { + return engine.history.count; +} + +export fn history_get(newest_index: u32, field: u32) i32 { + const h = &engine.history; + if (newest_index >= h.count) return -1; + const index = (h.next + history_capacity - 1 - newest_index) % history_capacity; + const event = &h.entries[index]; + return switch (field) { + 0 => asPublic(event.tick), + 1 => asPublic(event.kind), + 2 => asPublic(event.detail), + 3 => asPublic(event.revision), + else => -1, + }; +} + +export fn bytes(kind: u32) u32 { + return switch (kind) { + 0 => @sizeOf(Rules), + 1 => @sizeOf(State), + 2 => @sizeOf(Decision), + 3 => @sizeOf(History), + 4 => @sizeOf(Engine), + 5 => @sizeOf(@TypeOf(graph)), + 6 => @sizeOf(Engine) + @sizeOf(@TypeOf(graph)), + else => 0, + }; +} + +test "edits preserve complete simulation state and latest observation" { + reset(); + set_running(1); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(u32, 8), advance(8)); + const before_state = engine.state; + const before_decision = engine.decision; + try std.testing.expectEqual(@as(u32, 0), apply_rules(1, 16, 0)); + try std.testing.expectEqualDeep(before_state, engine.state); + try std.testing.expectEqualDeep(before_decision, engine.decision); + try std.testing.expectEqual(@as(i32, 1), get(9)); + try std.testing.expectEqual(@as(i32, 3), history_get(0, 1)); + try std.testing.expectEqual(@as(i32, 1), history_get(0, 3)); +} + +test "invalid edits and oversized advance are atomic" { + reset(); + set_running(1); + _ = input(2); + step(); + const before = engine; + const invalid = [_][3]u32{ + .{ 2, 10, 1 }, .{ 0, 5, 1 }, .{ 0, 17, 1 }, + .{ 0, 10, 2 }, .{ std.math.maxInt(u32), 10, 1 }, + }; + for (invalid) |candidate| { + try std.testing.expectEqual(@as(u32, 1), apply_rules(candidate[0], candidate[1], candidate[2])); + try std.testing.expectEqualDeep(before, engine); + } + try std.testing.expectEqual(@as(u32, 0), advance(601)); + try std.testing.expectEqual(@as(u32, 0), advance(std.math.maxInt(u32))); + try std.testing.expectEqualDeep(before, engine); +} + +test "pause, step, and ground-only jump handle input before integration" { + reset(); + try std.testing.expectEqual(@as(i32, -1), node_value(0)); + try std.testing.expectEqual(@as(i32, -1), decision_meta(0)); + try std.testing.expectEqual(@as(u32, 0), advance(600)); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(i32, 0), get(1)); + try std.testing.expectEqual(@as(i32, 1000), get(2)); + try std.testing.expectEqual(@as(i32, 0), get(17)); + try std.testing.expectEqual(@as(u32, 0), input(0)); + try std.testing.expectEqual(@as(i32, 3), get(10)); + step(); + try std.testing.expectEqual(@as(i32, 1), get(0)); + try std.testing.expectEqual(@as(i32, 945), get(1)); + try std.testing.expectEqual(@as(i32, 0), get(8)); + for (0..100) |_| step(); + try std.testing.expectEqual(@as(i32, 1), get(17)); + try std.testing.expectEqual(@as(i32, 0), get(7)); + try std.testing.expectEqual(@as(u32, 1), input(0)); +} + +test "double jump, disable, mode changes, and impulse apply at input boundaries" { + reset(); + _ = input(0); + for (0..5) |_| step(); + const old_velocity = get(2); + try std.testing.expectEqual(@as(u32, 0), apply_rules(1, 16, 1)); + try std.testing.expectEqual(old_velocity, get(2)); + try std.testing.expectEqual(@as(u32, 1), input(1)); + try std.testing.expectEqual(@as(i32, 1600), get(2)); + try std.testing.expectEqual(@as(i32, 2), get(7)); + try std.testing.expectEqual(@as(u32, 0), input(2)); + try std.testing.expectEqual(@as(i32, 4), get(10)); + _ = apply_rules(0, 6, 1); + try std.testing.expectEqual(@as(u32, 0), input(0)); + try std.testing.expectEqual(@as(i32, 3), get(10)); + for (0..150) |_| step(); + _ = apply_rules(1, 6, 0); + try std.testing.expectEqual(@as(u32, 0), input(0)); + try std.testing.expectEqual(@as(i32, 2), get(10)); + _ = apply_rules(1, 6, 1); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(i32, 600), get(2)); + try std.testing.expectEqual(@as(u32, 1), input(0)); + try std.testing.expectEqual(@as(u32, 0), input(0)); +} + +test "all input sources share transition behavior and retain provenance" { + var reference: State = undefined; + for (0..3) |source| { + reset(); + try std.testing.expectEqual(@as(u32, 1), input(@intCast(source))); + for (0..7) |_| step(); + try std.testing.expectEqual(@as(i32, @intCast(source)), get(11)); + try std.testing.expectEqual(@as(i32, @intCast(source)), history_get(0, 2)); + var normalized = engine.state; + normalized.last_source = 0; + if (source == 0) reference = normalized else try std.testing.expectEqualDeep(reference, normalized); + } + reset(); + try std.testing.expectEqual(@as(u32, 0), input(std.math.maxInt(u32))); + try std.testing.expectEqual(@as(i32, 5), get(10)); + try std.testing.expectEqual(@as(i32, 0), get(2)); + try std.testing.expectEqual(@as(i32, 0), node_value(0)); + try std.testing.expectEqual(@as(i32, 5), history_get(0, 2)); +} + +test "fixed history overwrites oldest entry and handles invalid access" { + reset(); + for (0..40) |i| { + _ = apply_rules(@intCast(i % 2), 10, 1); + step(); + } + try std.testing.expectEqual(@as(u32, 16), history_count()); + for (0..16) |i| { + try std.testing.expectEqual(@as(i32, @intCast(40 - i)), history_get(@intCast(i), 3)); + try std.testing.expectEqual(@as(i32, @intCast(39 - i)), history_get(@intCast(i), 0)); + } + try std.testing.expectEqual(@as(i32, -1), history_get(16, 0)); + try std.testing.expectEqual(@as(i32, -1), history_get(0, 4)); + try std.testing.expectEqual(@as(i32, -1), history_get(std.math.maxInt(u32), 0)); +} + +test "decision is pre-input state and remains at its original revision" { + reset(); + _ = apply_rules(1, 10, 1); + step(); + _ = input(2); + try std.testing.expectEqual(@as(i32, 1), decision_meta(0)); + try std.testing.expectEqual(@as(i32, 1), decision_meta(1)); + try std.testing.expectEqual(@as(i32, 0), decision_meta(2)); + try std.testing.expectEqual(@as(i32, 0), decision_meta(3)); + try std.testing.expectEqual(@as(i32, 1), get(7)); + try std.testing.expectEqual(@as(i32, 1), node_value(2)); + try std.testing.expectEqual(@as(i32, 1), node_value(5)); + const snapshot = engine.decision; + _ = apply_rules(0, 16, 0); + step(); + try std.testing.expectEqualDeep(snapshot, engine.decision); + try std.testing.expectEqual(@as(i32, -1), node_value(6)); + try std.testing.expectEqual(@as(i32, -1), decision_meta(4)); +} + +test "graph truth table agrees with a direct permission predicate" { + for (0..2) |mode| { + for (0..2) |enabled| { + for (0..2) |airborne| { + for (0..2) |pending_jump| { + for (0..3) |jumps| { + for (0..4) |source| { + reset(); + engine.rules.mode = @intCast(mode); + engine.rules.enabled = @intCast(enabled); + engine.state.y = if (airborne == 1) 500 else 0; + engine.state.vy = if (pending_jump == 1) 1000 else 0; + engine.state.jumps_used = @intCast(jumps); + const on_ground = airborne == 0 and pending_jump == 0; + const permission = if (mode == 0) on_ground else jumps < 2; + const expected = source < 3 and enabled == 1 and permission; + const accepted = input(@intCast(source)); + try std.testing.expectEqual(@as(u32, @intFromBool(expected)), accepted); + try std.testing.expectEqual(@as(i32, @intFromBool(on_ground)), node_value(2)); + try std.testing.expectEqual(@as(i32, @intFromBool(jumps < 2)), node_value(3)); + try std.testing.expectEqual(@as(i32, @intFromBool(permission)), node_value(4)); + try std.testing.expectEqual(@as(i32, @intFromBool(expected)), node_value(5)); + } + } + } + } + } + } +} + +fn replay() Engine { + reset(); + set_running(1); + for (0..3000) |i| { + if (i % 127 == 0) _ = apply_rules(@intCast((i / 127) % 2), @intCast(6 + i % 11), @intFromBool(i % 254 != 0)); + if (i % 29 == 0) _ = input(@intCast((i / 29) % 3)); + _ = advance(1); + } + return engine; +} + +test "replay includes respawns and produces identical complete engine state" { + const first = replay(); + const second = replay(); + try std.testing.expectEqualDeep(first, second); + try std.testing.expect(first.state.passed > 5); + try std.testing.expect(first.state.hits > 0); + try std.testing.expectEqual(@as(u32, 3000), first.state.tick); + try std.testing.expectEqual(@as(u32, 12000), first.state.distance); +} + +test "collisions do not reset progress and each obstacle is counted once" { + reset(); + set_running(1); + _ = advance(180); + try std.testing.expectEqual(@as(i32, 1), get(6)); + try std.testing.expectEqual(@as(i32, 1), get(5)); + try std.testing.expectEqual(@as(i32, 180), get(0)); + try std.testing.expectEqual(@as(i32, 720), get(3)); + _ = advance(10); + try std.testing.expectEqual(@as(i32, 1), get(6)); + try std.testing.expectEqual(@as(i32, 1), get(5)); + try std.testing.expectEqual(@as(i32, 1), get(8)); +} + +test "counters wrap explicitly and memory accounting has no overlapping total" { + reset(); + engine.state.tick = std.math.maxInt(u32); + engine.state.distance = std.math.maxInt(u32) - 1; + engine.state.total_inputs = std.math.maxInt(u32); + engine.state.accepted_jumps = std.math.maxInt(u32); + engine.rules.revision = std.math.maxInt(u32); + _ = input(0); + _ = apply_rules(1, 10, 1); + step(); + try std.testing.expectEqual(@as(i32, 0), get(0)); + try std.testing.expectEqual(@as(i32, 2), get(3)); + try std.testing.expectEqual(@as(i32, 0), get(9)); + try std.testing.expectEqual(@as(i32, 0), get(12)); + try std.testing.expectEqual(@as(i32, 0), get(13)); + try std.testing.expectEqual(bytes(4) + bytes(5), bytes(6)); + try std.testing.expect(bytes(4) >= bytes(0) + bytes(1) + bytes(2) + bytes(3)); + try std.testing.expectEqual(@as(u32, 0), bytes(7)); + try std.testing.expectEqual(@as(i32, -1), get(19)); +} diff --git a/prototypes/live-rules/manage.py b/prototypes/live-rules/manage.py new file mode 100644 index 0000000..fbe047c --- /dev/null +++ b/prototypes/live-rules/manage.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Build/test the shared Zig core or serve the local experiment. No Python dependencies.""" +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 +VERSION = "0.15.2" + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def build(zig): + compiler = shutil.which(zig) + if compiler is None: + raise SystemExit("Zig 0.15.2가 필요합니다. ziglang.org/download 또는 --zig /path/to/zig를 사용하세요.") + version = subprocess.check_output([compiler, "version"], text=True).strip() + if version != VERSION: + raise SystemExit(f"이 실험은 Zig {VERSION}로 고정했습니다. 현재: {version}") + build_dir = ROOT / "build" + build_dir.mkdir(exist_ok=True) + cache = ["--cache-dir", str(build_dir / "cache"), + "--global-cache-dir", str(build_dir / "global-cache")] + source = ROOT / "core.zig" + native = [compiler, "test", str(source), *cache] + print("Checking native core…", flush=True) + checked = subprocess.run(native, capture_output=True, text=True) + print(checked.stdout + checked.stderr, end="", flush=True) + checked.check_returncode() + wasm = ROOT / "web" / "core.wasm" + wasm.parent.mkdir(exist_ok=True) + flags = ["-target", "wasm32-freestanding", "-O", "ReleaseSafe", "-fno-entry", + "-rdynamic", "--export-memory", "--stack", "16384", + "--initial-memory=65536", "--max-memory=65536"] + command = [compiler, "build-exe", str(source), *flags, f"-femit-bin={wasm}", *cache] + subprocess.run(command, check=True) + (build_dir / "build.json").write_text(json.dumps({ + "built_utc": datetime.now(timezone.utc).isoformat(), + "zig_version": version, "flags": flags, + "source_sha256": digest(source), "wasm_sha256": digest(wasm), + "wasm_file_bytes": wasm.stat().st_size, + "native_test_output": checked.stdout + checked.stderr, + }, ensure_ascii=False, indent=2) + "\n") + print(f"Built {wasm} ({wasm.stat().st_size:,} bytes)") + + +class Handler(SimpleHTTPRequestHandler): + extensions_map = {**SimpleHTTPRequestHandler.extensions_map, ".wasm": "application/wasm"} + + 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) + compile_cmd = sub.add_parser("build", help="native tests + WebAssembly build") + compile_cmd.add_argument("--zig", default="zig") + serve = sub.add_parser("serve", help="serve web/ on loopback only") + serve.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + if args.action == "build": + build(args.zig) + return + if not 1 <= args.port <= 65535: + parser.error("port must be between 1 and 65535") + if not (ROOT / "web" / "core.wasm").is_file(): + parser.error("먼저 manage.py build를 실행하세요.") + handler = partial(Handler, directory=str(ROOT / "web")) + with ThreadingHTTPServer(("127.0.0.1", args.port), handler) as server: + print(f"Pure: http://127.0.0.1:{args.port} (Ctrl+C to stop)", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/prototypes/live-rules/measure.mjs b/prototypes/live-rules/measure.mjs new file mode 100644 index 0000000..2f12815 --- /dev/null +++ b/prototypes/live-rules/measure.mjs @@ -0,0 +1,48 @@ +// Reproducible storage ledger + exact state transitions, not a speed benchmark. +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { arch, platform, release } from 'node:os'; +import { loadCore, snapshot, storage, parseCommand, execute } from './runtime.mjs'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const destination = process.argv[2]; +if (!destination || process.argv.length !== 3) throw new Error('Usage: node measure.mjs NEW_OUTPUT_DIRECTORY'); +const core = await loadCore(); +const checked = spawnSync(process.execPath, [resolve(root, 'adapter.test.mjs')], { encoding: 'utf8' }); +if (checked.error || checked.status !== 0) throw new Error(checked.error?.message || checked.stdout + checked.stderr); +process.stdout.write(checked.stdout + checked.stderr); + +const trace = ['jump', 'step 8', 'jump', 'rules double 12 on', 'jump', 'step 60']; +const steps = trace.map((command) => ({ command, ...execute(core, parseCommand(command)) })); +const baseline = storage(core); +const imports = WebAssembly.Module.imports(await WebAssembly.compile(await readFile(resolve(root, 'web/core.wasm')))); +const hashes = {}; +for (const name of ['core.zig', 'web/core.wasm', 'runtime.mjs', 'cli.mjs', 'adapter.test.mjs', 'measure.mjs', 'manage.py', 'qa-browser.js', 'web/app.js', 'web/index.html', 'web/style.css']) { + const data = await readFile(resolve(root, name)); + hashes[name] = { bytes: data.length, sha256: createHash('sha256').update(data).digest('hex') }; +} +let build = null; +try { + build = JSON.parse(await readFile(resolve(root, 'build/build.json'), 'utf8')); + if (build.source_sha256 !== hashes['core.zig'].sha256 || build.wasm_sha256 !== hashes['web/core.wasm'].sha256) { + throw new Error('Build record and current source/binary differ. Rebuild first.'); + } +} catch (error) { + if (error.code !== 'ENOENT') throw error; +} + +const result = { + recordedUtc: new Date().toISOString(), + host: { platform: platform(), release: release(), arch: arch(), node: process.version }, + scope: 'Declared Zig type layouts and actual Wasm memory capacity; excludes JS/DOM/renderer, code pages and RSS. No latency or throughput measurement.', + imports, storage: baseline, trace: steps, + finalState: snapshot(core), build, files: hashes, + adapterTests: { command: 'node prototypes/live-rules/adapter.test.mjs', exitCode: checked.status, + output: checked.stdout + checked.stderr }, +}; +await mkdir(destination, { recursive: false }); +await writeFile(resolve(destination, 'storage-and-replay.json'), JSON.stringify(result, null, 2) + '\n'); +console.log(`Saved ${resolve(destination, 'storage-and-replay.json')}`); diff --git a/prototypes/live-rules/qa-browser.js b/prototypes/live-rules/qa-browser.js new file mode 100644 index 0000000..ee11c3a --- /dev/null +++ b/prototypes/live-rules/qa-browser.js @@ -0,0 +1,131 @@ +// Pass this expression to Playwright CLI's run-code after opening the local demo. +// Interactions use real UI controls; the read-only snapshot only observes results. +async (page) => { + const checks = []; + const errors = []; + const onError = (error) => errors.push(error.message); + const check = (condition, label) => { + if (!condition) throw new Error(label); + checks.push(label); + }; + const state = () => page.evaluate(() => window.pureDemo.snapshot()); + const button = (name) => page.getByRole('button', { name, exact: true }); + const step = async (count) => { for (let i = 0; i < count; i++) await button('한 프레임').click(); }; + page.on('pageerror', onError); + try { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.reload(); + await page.waitForFunction(() => window.pureDemo?.snapshot().ready); + const initial = await state(); + check(initial.running === 0 && initial.tick === 0 && initial.revision === 0, 'initial state is paused'); + + await page.locator('#stage').focus(); + await page.keyboard.press('Space'); + let s = await state(); + check(s.accepted === 1 && s.inputs === 1 && s.source === 0 && s.y === 0, 'Space goes through keyboard input once while paused'); + await step(8); + await button('점프 Space').click(); + const before = await state(); + check(before.tick === 8 && before.y === 6020 && before.vy === 560 && before.reason === 3, 'ground rule rejects a second airborne jump'); + await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check(); + const draft = await state(); + check(draft.mode === 0 && draft.revision === 0 && draft.draftDirty, 'draft changes do not execute before Apply'); + await button('규칙 적용').click(); + const applied = await state(); + check(['tick', 'y', 'vy', 'distance', 'obstacleX', 'jumps'].every((key) => applied[key] === before[key]), 'applying rules preserves exact simulation progress'); + check(applied.mode === 1 && applied.revision === 1 && applied.decisionRevision === 0 && await page.locator('#stale-decision').isVisible(), 'old decision retains and displays its revision'); + await button('점프 Space').click(); + const second = await state(); + check(second.reason === 1 && second.jumps === 2 && second.source === 1 && second.decisionRevision === 1, 'pointer input uses new rule and accepts second jump'); + await button('점프 Space').click(); + check((await state()).reason === 4, 'third jump is rejected'); + + await page.getByRole('checkbox').uncheck(); + await button('규칙 적용').click(); + await button('점프 Space').click(); + check((await state()).reason === 2, 'disabled rule explains rejection'); + await button('처음부터').click(); + s = await state(); + check(s.tick === 0 && s.mode === 0 && s.impulse === 10 && s.enabled === 1 && s.inputs === 0 && s.running === 0, 'explicit reset restores rules and state'); + + await page.getByRole('slider', { name: '점프 세기' }).focus(); + await page.keyboard.press('Space'); + await page.getByRole('checkbox').focus(); + await page.keyboard.press('Space'); + check((await state()).inputs === 0, 'Space in form controls never dispatches a jump'); + await button('처음부터').click(); + await button('점프 Space').focus(); + await page.keyboard.press('Space'); + check((await state()).inputs === 1, 'Space on the jump button does not double dispatch'); + await button('처음부터').click(); + await page.locator('#stage').focus(); + await page.keyboard.down('Space'); + await page.keyboard.down('Space'); + await page.keyboard.up('Space'); + check((await state()).inputs === 1, 'keyboard repeat does not add a second request'); + + await button('처음부터').click(); + await button('실행').click(); + await page.waitForFunction(() => window.pureDemo.snapshot().tick >= 6); + await button('일시정지').click(); + const paused = await state(); + await page.waitForTimeout(120); + check(paused.tick >= 6 && paused.tick === (await state()).tick, 'running advances and pause freezes simulation'); + + await button('처음부터').click(); + await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check(); + await page.getByRole('slider', { name: '점프 세기' }).focus(); + await page.keyboard.press('End'); + await button('규칙 적용').click(); + await button('점프 Space').click(); + await step(28); + await button('점프 Space').click(); + await step(29); + const high = await state(); + const visible = await page.evaluate(() => { + const d = document.getElementById('dino').getBoundingClientRect(); + const w = document.getElementById('world').getBoundingClientRect(); + return d.top >= w.top && d.bottom <= w.bottom && d.left >= w.left && d.right <= w.right; + }); + check(high.y > 40000 && visible, 'maximum-height double jump remains inside the scene'); + + for (let i = 0; i < 25; i++) await button('점프 Space').click(); + check(await page.locator('#history-list li').count() === 8, 'visible history is bounded to eight rows'); + check((await state()).wasmMemoryBytes === 65536, 'browser instance retains fixed 64 KiB linear memory'); + await page.locator('.research-panel summary').click(); + check((await page.locator('#bytes-total').textContent()).includes('396'), 'research panel shows logical layout separately'); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator('.research-panel summary').click(); + await button('처음부터').click(); + const width = await page.evaluate(() => ({ viewport: innerWidth, content: document.documentElement.scrollWidth })); + check(width.content <= width.viewport, '390px viewport has no horizontal overflow'); + check(await page.locator('#dino').evaluate((el) => el.getBoundingClientRect().height) >= 28, 'narrow view keeps the dinosaur large enough to inspect'); + await button('점프 Space').click(); + await step(1); + check((await state()).y === 945, 'narrow viewport controls remain usable'); + await page.screenshot({ path: 'output/playwright/pure-live-mobile.png', fullPage: true }); + + await page.route('**/core.wasm', (route) => route.abort()); + await page.reload(); + await page.locator('#load-error').waitFor({ state: 'visible' }); + check(await button('실행').isDisabled(), 'missing Wasm has an actionable error and disabled controls'); + await page.unroute('**/core.wasm'); + await page.reload(); + await page.waitForFunction(() => window.pureDemo?.snapshot().ready); + await page.setViewportSize({ width: 1440, height: 1000 }); + await button('점프 Space').click(); + await step(8); + await button('점프 Space').click(); + await page.getByRole('radio', { name: '두 번 점프 공중에서 한 번 더 뛸 수 있어요.', exact: true }).check(); + await button('규칙 적용').click(); + await button('점프 Space').click(); + await page.screenshot({ path: 'output/playwright/pure-live-desktop.png', fullPage: true }); + check(errors.length === 0, 'no uncaught page errors'); + return { recordedUtc: new Date().toISOString(), browser: await page.evaluate(() => navigator.userAgent), + checks, before, applied, second, high, mobile: width, errors, + screenshots: ['output/playwright/pure-live-desktop.png', 'output/playwright/pure-live-mobile.png'] }; + } finally { + page.off('pageerror', onError); + } +} diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json b/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json new file mode 100644 index 0000000..efb9ec7 --- /dev/null +++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/browser-checks.json @@ -0,0 +1,215 @@ +{ + "recordedUtc": "2026-09-22T10:20:31.347Z", + "browser": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36", + "checks": [ + "initial state is paused", + "Space goes through keyboard input once while paused", + "ground rule rejects a second airborne jump", + "draft changes do not execute before Apply", + "applying rules preserves exact simulation progress", + "old decision retains and displays its revision", + "pointer input uses new rule and accepts second jump", + "third jump is rejected", + "disabled rule explains rejection", + "explicit reset restores rules and state", + "Space in form controls never dispatches a jump", + "Space on the jump button does not double dispatch", + "keyboard repeat does not add a second request", + "running advances and pause freezes simulation", + "maximum-height double jump remains inside the scene", + "visible history is bounded to eight rows", + "browser instance retains fixed 64 KiB linear memory", + "research panel shows logical layout separately", + "390px viewport has no horizontal overflow", + "narrow view keeps the dinosaur large enough to inspect", + "narrow viewport controls remain usable", + "missing Wasm has an actionable error and disabled controls", + "no uncaught page errors" + ], + "before": { + "ready": true, + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumps": 1, + "running": 0, + "revision": 0, + "reason": 3, + "source": 1, + "inputs": 2, + "accepted": 1, + "mode": 0, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 11, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 8, + "decisionRevision": 0, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 0, + 0 + ] + }, + "applied": { + "ready": true, + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumps": 1, + "running": 0, + "revision": 1, + "reason": 3, + "source": 1, + "inputs": 2, + "accepted": 1, + "mode": 1, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 12, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 8, + "decisionRevision": 0, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 0, + 0 + ] + }, + "second": { + "ready": true, + "tick": 8, + "y": 6020, + "vy": 1000, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumps": 2, + "running": 0, + "revision": 1, + "reason": 1, + "source": 1, + "inputs": 3, + "accepted": 2, + "mode": 1, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 13, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 8, + "decisionRevision": 1, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "high": { + "ready": true, + "tick": 57, + "y": 44945, + "vy": 5, + "distance": 228, + "obstacleX": 572, + "passed": 0, + "hits": 0, + "jumps": 2, + "running": 0, + "revision": 1, + "reason": 1, + "source": 1, + "inputs": 2, + "accepted": 2, + "mode": 1, + "impulse": 16, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48, + "draftDirty": false, + "droppedTicks": 0, + "renderCount": 92, + "wasmMemoryBytes": 65536, + "logicalBytes": 396, + "decisionTick": 28, + "decisionRevision": 1, + "decisionNodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "mobile": { + "viewport": 390, + "content": 390 + }, + "errors": [], + "screenshots": [ + { + "file": "desktop.png", + "sha256": "ae02e473b472816dccf147e1d7b0a45135ffbc10fbef97002a7f0697303a7fbb" + }, + { + "file": "mobile.png", + "sha256": "a3295e68fdaff2064d9076c0e136a80062abd0dd58ef770aa9b7c7dff356926d" + } + ], + "playwrightCliVersion": "0.1.21", + "files": { + "qa-browser.js": { + "sha256": "50d4293d41d5aa63b404c3e22fdc1349008765365fac8cdafe01dfdf858893da", + "bytes": 7554 + }, + "web/app.js": { + "sha256": "b3dcd10fa4e4ab56dff200e9238c640e6dbea5fa7b624328d7d421ee8744e778", + "bytes": 14358 + }, + "web/index.html": { + "sha256": "28630577d3faa81b61fec95723d421e1cac9a576452d909342cfd054d3dea385", + "bytes": 13396 + }, + "web/style.css": { + "sha256": "b399be728d8f2bfb1964903e11e69a9bccbc83c9d90ecfd6783110c7a0702923", + "bytes": 16606 + }, + "web/core.wasm": { + "sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5", + "bytes": 32544 + } + } +} diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png new file mode 100644 index 0000000..5318b55 Binary files /dev/null and b/prototypes/live-rules/results/2026-09-22-linux-aarch64/desktop.png differ diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png b/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png new file mode 100644 index 0000000..2f11c77 Binary files /dev/null and b/prototypes/live-rules/results/2026-09-22-linux-aarch64/mobile.png differ diff --git a/prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json b/prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json new file mode 100644 index 0000000..aab0d2a --- /dev/null +++ b/prototypes/live-rules/results/2026-09-22-linux-aarch64/storage-and-replay.json @@ -0,0 +1,503 @@ +{ + "recordedUtc": "2026-09-22T10:21:30.811Z", + "host": { + "platform": "linux", + "release": "6.18.39+rpt-rpi-2712", + "arch": "arm64", + "node": "v24.20.0" + }, + "scope": "Declared Zig type layouts and actual Wasm memory capacity; excludes JS/DOM/renderer, code pages and RSS. No latency or throughput measurement.", + "imports": [], + "storage": { + "layoutBytes": { + "rules": 16, + "state": 68, + "decision": 24, + "history": 264, + "engine": 372, + "graph": 24, + "logicalTotal": 396 + }, + "wasmLinearMemoryBytes": 65536, + "note": "Type-layout sizes; constant graph may be compiled into code. Not browser heap or RSS." + }, + "trace": [ + { + "command": "jump", + "state": { + "tick": 0, + "y": 0, + "vy": 1000, + "distance": 0, + "obstacleX": 800, + "passed": 0, + "hits": 0, + "jumpsUsed": 1, + "running": 0, + "revision": 0, + "lastReason": 1, + "lastSource": 2, + "totalInputs": 1, + "acceptedJumps": 1, + "mode": 0, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48 + }, + "decision": { + "tick": 0, + "revision": 0, + "y": 0, + "jumps": 0, + "nodes": [ + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "history": [ + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + }, + { + "command": "step 8", + "state": { + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumpsUsed": 1, + "running": 0, + "revision": 0, + "lastReason": 1, + "lastSource": 2, + "totalInputs": 1, + "acceptedJumps": 1, + "mode": 0, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48 + }, + "decision": { + "tick": 0, + "revision": 0, + "y": 0, + "jumps": 0, + "nodes": [ + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "history": [ + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + }, + { + "command": "jump", + "state": { + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumpsUsed": 1, + "running": 0, + "revision": 0, + "lastReason": 3, + "lastSource": 2, + "totalInputs": 2, + "acceptedJumps": 1, + "mode": 0, + "impulse": 10, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48 + }, + "decision": { + "tick": 8, + "revision": 0, + "y": 6020, + "jumps": 1, + "nodes": [ + 1, + 1, + 0, + 1, + 0, + 0 + ] + }, + "history": [ + { + "tick": 8, + "kind": 2, + "detail": 3, + "revision": 0 + }, + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + }, + { + "command": "rules double 12 on", + "state": { + "tick": 8, + "y": 6020, + "vy": 560, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumpsUsed": 1, + "running": 0, + "revision": 1, + "lastReason": 3, + "lastSource": 2, + "totalInputs": 2, + "acceptedJumps": 1, + "mode": 1, + "impulse": 12, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48 + }, + "decision": { + "tick": 8, + "revision": 0, + "y": 6020, + "jumps": 1, + "nodes": [ + 1, + 1, + 0, + 1, + 0, + 0 + ] + }, + "history": [ + { + "tick": 8, + "kind": 3, + "detail": 1, + "revision": 1 + }, + { + "tick": 8, + "kind": 2, + "detail": 3, + "revision": 0 + }, + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + }, + { + "command": "jump", + "state": { + "tick": 8, + "y": 6020, + "vy": 1200, + "distance": 32, + "obstacleX": 768, + "passed": 0, + "hits": 0, + "jumpsUsed": 2, + "running": 0, + "revision": 1, + "lastReason": 1, + "lastSource": 2, + "totalInputs": 3, + "acceptedJumps": 2, + "mode": 1, + "impulse": 12, + "enabled": 1, + "grounded": 0, + "obstacleHeight": 48 + }, + "decision": { + "tick": 8, + "revision": 1, + "y": 6020, + "jumps": 1, + "nodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "history": [ + { + "tick": 8, + "kind": 1, + "detail": 2, + "revision": 1 + }, + { + "tick": 8, + "kind": 3, + "detail": 1, + "revision": 1 + }, + { + "tick": 8, + "kind": 2, + "detail": 3, + "revision": 0 + }, + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + }, + { + "command": "step 60", + "state": { + "tick": 68, + "y": 0, + "vy": 0, + "distance": 272, + "obstacleX": 528, + "passed": 0, + "hits": 0, + "jumpsUsed": 0, + "running": 0, + "revision": 1, + "lastReason": 1, + "lastSource": 2, + "totalInputs": 3, + "acceptedJumps": 2, + "mode": 1, + "impulse": 12, + "enabled": 1, + "grounded": 1, + "obstacleHeight": 48 + }, + "decision": { + "tick": 8, + "revision": 1, + "y": 6020, + "jumps": 1, + "nodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "history": [ + { + "tick": 56, + "kind": 4, + "detail": 0, + "revision": 1 + }, + { + "tick": 8, + "kind": 1, + "detail": 2, + "revision": 1 + }, + { + "tick": 8, + "kind": 3, + "detail": 1, + "revision": 1 + }, + { + "tick": 8, + "kind": 2, + "detail": 3, + "revision": 0 + }, + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + } + ], + "finalState": { + "state": { + "tick": 68, + "y": 0, + "vy": 0, + "distance": 272, + "obstacleX": 528, + "passed": 0, + "hits": 0, + "jumpsUsed": 0, + "running": 0, + "revision": 1, + "lastReason": 1, + "lastSource": 2, + "totalInputs": 3, + "acceptedJumps": 2, + "mode": 1, + "impulse": 12, + "enabled": 1, + "grounded": 1, + "obstacleHeight": 48 + }, + "decision": { + "tick": 8, + "revision": 1, + "y": 6020, + "jumps": 1, + "nodes": [ + 1, + 1, + 0, + 1, + 1, + 1 + ] + }, + "history": [ + { + "tick": 56, + "kind": 4, + "detail": 0, + "revision": 1 + }, + { + "tick": 8, + "kind": 1, + "detail": 2, + "revision": 1 + }, + { + "tick": 8, + "kind": 3, + "detail": 1, + "revision": 1 + }, + { + "tick": 8, + "kind": 2, + "detail": 3, + "revision": 0 + }, + { + "tick": 0, + "kind": 1, + "detail": 2, + "revision": 0 + } + ] + }, + "build": { + "built_utc": "2026-09-22T10:01:20.063882+00:00", + "zig_version": "0.15.2", + "flags": [ + "-target", + "wasm32-freestanding", + "-O", + "ReleaseSafe", + "-fno-entry", + "-rdynamic", + "--export-memory", + "--stack", + "16384", + "--initial-memory=65536", + "--max-memory=65536" + ], + "source_sha256": "d9fdba86e700605b053586d95ccdfb47cbe87070717182fb1c2ab7b45221ad2b", + "wasm_sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5", + "wasm_file_bytes": 32544, + "native_test_output": "1/11 core.test.edits preserve complete simulation state and latest observation...OK\n2/11 core.test.invalid edits and oversized advance are atomic...OK\n3/11 core.test.pause, step, and ground-only jump handle input before integration...OK\n4/11 core.test.double jump, disable, mode changes, and impulse apply at input boundaries...OK\n5/11 core.test.all input sources share transition behavior and retain provenance...OK\n6/11 core.test.fixed history overwrites oldest entry and handles invalid access...OK\n7/11 core.test.decision is pre-input state and remains at its original revision...OK\n8/11 core.test.graph truth table agrees with a direct permission predicate...OK\n9/11 core.test.replay includes respawns and produces identical complete engine state...OK\n10/11 core.test.collisions do not reset progress and each obstacle is counted once...OK\n11/11 core.test.counters wrap explicitly and memory accounting has no overlapping total...OK\nAll 11 tests passed.\n" + }, + "files": { + "core.zig": { + "bytes": 17914, + "sha256": "d9fdba86e700605b053586d95ccdfb47cbe87070717182fb1c2ab7b45221ad2b" + }, + "web/core.wasm": { + "bytes": 32544, + "sha256": "ea0789dcbb0cc2d6a17e049f4568374d242b44dafe7f996673538f3bd6dafed5" + }, + "runtime.mjs": { + "bytes": 3939, + "sha256": "9d01626245d1e0c6261a28a9ecacb04c56c67dfcb901958526d3ccb234bef159" + }, + "cli.mjs": { + "bytes": 2998, + "sha256": "6b39eebef35b5c19db01dee4d254ad667e42a5633c840b29d84607f37e98ef8f" + }, + "adapter.test.mjs": { + "bytes": 3701, + "sha256": "6f81dc74cd2e2b38390e7bcca4c4757e8a50b58ef65194eab871192c4e86cf66" + }, + "measure.mjs": { + "bytes": 2810, + "sha256": "4125f3e98d3d941bd1a61c389a91d3a1e0cd7e1bc14c6fedd87094ae6f0948dd" + }, + "manage.py": { + "bytes": 3559, + "sha256": "d9f1b13e635e15dff526a2f082144570e13bc18e73d9d85da1c1804de82a3e32" + }, + "qa-browser.js": { + "bytes": 7554, + "sha256": "50d4293d41d5aa63b404c3e22fdc1349008765365fac8cdafe01dfdf858893da" + }, + "web/app.js": { + "bytes": 14358, + "sha256": "b3dcd10fa4e4ab56dff200e9238c640e6dbea5fa7b624328d7d421ee8744e778" + }, + "web/index.html": { + "bytes": 13396, + "sha256": "28630577d3faa81b61fec95723d421e1cac9a576452d909342cfd054d3dea385" + }, + "web/style.css": { + "bytes": 16606, + "sha256": "b399be728d8f2bfb1964903e11e69a9bccbc83c9d90ecfd6783110c7a0702923" + } + }, + "adapterTests": { + "command": "node prototypes/live-rules/adapter.test.mjs", + "exitCode": 0, + "output": "✔ the actual Wasm preserves airborne state through a rule change, then permits the second jump (9.168858ms)\n✔ bad CLI edits and malformed numeric input cannot silently coerce into a different rule (1.968798ms)\n✔ keyboard, pointer and terminal requests give the same simulation result (2.355835ms)\n✔ paused input has an inspectable decision; advance cannot move paused state (2.338743ms)\n✔ bounded history and fixed linear memory survive repeated play and edits (4.602115ms)\n✔ replaying the same CLI operations produces identical snapshots and journal (3.481058ms)\nℹ tests 6\nℹ suites 0\nℹ pass 6\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 32.277966\n" + } +} diff --git a/prototypes/live-rules/runtime.mjs b/prototypes/live-rules/runtime.mjs new file mode 100644 index 0000000..9c854ea --- /dev/null +++ b/prototypes/live-rules/runtime.mjs @@ -0,0 +1,100 @@ +// Node adapter only. All rule evaluation, physics and persistent state live in Zig. +import { readFile } from 'node:fs/promises'; + +export const fields = [ + 'tick', 'y', 'vy', 'distance', 'obstacleX', 'passed', 'hits', 'jumpsUsed', + 'running', 'revision', 'lastReason', 'lastSource', 'totalInputs', + 'acceptedJumps', 'mode', 'impulse', 'enabled', 'grounded', 'obstacleHeight', +]; +const signed = new Set(['y', 'vy', 'obstacleX']); + +export async function loadCore() { + const binary = await readFile(new URL('./web/core.wasm', import.meta.url)); + const module = await WebAssembly.compile(binary); + if (WebAssembly.Module.imports(module).length !== 0) throw new Error('Core must not import a host runtime.'); + const instance = await WebAssembly.instantiate(module, {}); + instance.exports.reset(); + return instance.exports; +} + +export function snapshot(core) { + const state = Object.fromEntries(fields.map((name, id) => { + const value = core.get(id); + return [name, signed.has(name) ? value : value >>> 0]; + })); + return { + state, + decision: core.node_value(0) === -1 ? null : { + tick: core.decision_meta(0) >>> 0, + revision: core.decision_meta(1) >>> 0, + y: core.decision_meta(2), + jumps: core.decision_meta(3) >>> 0, + nodes: Array.from({ length: 6 }, (_, i) => core.node_value(i)), + }, + history: Array.from({ length: core.history_count() }, (_, i) => ({ + tick: core.history_get(i, 0) >>> 0, + kind: core.history_get(i, 1), + detail: core.history_get(i, 2) >>> 0, + revision: core.history_get(i, 3) >>> 0, + })), + }; +} + +export function storage(core) { + const keys = ['rules', 'state', 'decision', 'history', 'engine', 'graph', 'logicalTotal']; + return { + layoutBytes: Object.fromEntries(keys.map((key, i) => [key, core.bytes(i)])), + wasmLinearMemoryBytes: core.memory.buffer.byteLength, + note: 'Type-layout sizes; constant graph may be compiled into code. Not browser heap or RSS.', + }; +} + +export function parseCommand(line) { + const tokens = line.trim().split(/\s+/); + const name = tokens[0]; + if (!name) return { op: 'status' }; + if (['status', 'inspect', 'memory', 'reset', 'jump', 'help', 'quit'].includes(name)) { + if (tokens.length !== 1) throw new Error(`${name}: 인수가 필요하지 않습니다.`); + return { op: name }; + } + if (name === 'step') { + if (tokens.length > 2) throw new Error('사용법: step [1..600]'); + const count = tokens.length === 1 ? 1 : integer(tokens[1], 1, 600); + return { op: 'step', count }; + } + if (name === 'rules') { + if (tokens.length !== 4 || !['ground', 'double'].includes(tokens[1]) || !['on', 'off'].includes(tokens[3])) { + throw new Error('사용법: rules ground|double 6..16 on|off'); + } + return { op: 'rules', mode: tokens[1] === 'double' ? 1 : 0, + impulse: integer(tokens[2], 6, 16), enabled: tokens[3] === 'on' ? 1 : 0 }; + } + throw new Error('알 수 없는 명령입니다. help를 입력하세요.'); +} + +function integer(text, min, max) { + if (!/^\d+$/.test(text)) throw new Error(`${min}..${max} 범위의 정수를 입력하세요.`); + const value = Number(text); + if (!Number.isSafeInteger(value) || value < min || value > max) { + throw new Error(`${min}..${max} 범위의 정수를 입력하세요.`); + } + return value; +} + +export function execute(core, command) { + switch (command.op) { + case 'jump': core.input(2); break; + case 'step': + for (let n = 0; n < command.count; n += 1) core.step(); + break; + case 'rules': + if (core.apply_rules(command.mode, command.impulse, command.enabled) !== 0) { + throw new Error('규칙 전체를 거절했습니다. 이전 규칙과 상태를 유지합니다.'); + } + break; + case 'reset': core.reset(); break; + case 'status': case 'inspect': case 'memory': break; + default: throw new Error('실행할 수 없는 명령입니다.'); + } + return snapshot(core); +} diff --git a/prototypes/live-rules/web/app.js b/prototypes/live-rules/web/app.js new file mode 100644 index 0000000..d8ca815 --- /dev/null +++ b/prototypes/live-rules/web/app.js @@ -0,0 +1,294 @@ +"use strict"; + +(() => { + const $ = (id) => document.getElementById(id); + const field = Object.freeze({ tick: 0, y: 1, vy: 2, distance: 3, obstacleX: 4, passed: 5, hits: 6, jumps: 7, running: 8, revision: 9, reason: 10, source: 11, inputs: 12, accepted: 13, mode: 14, impulse: 15, enabled: 16, grounded: 17, obstacleHeight: 18 }); + const reasonText = ["아직 입력 없음", "점프를 받아들였어요.", "점프 입력이 꺼져 있어요.", "땅에 닿아야 다시 뛸 수 있어요.", "공중에서 쓸 점프를 모두 썼어요.", "알 수 없는 입력이에요."]; + const sourceText = ["키보드", "점프 버튼", "CLI"]; + const frameMs = 1000 / 60; + let core = null; + let ready = false; + let applied = null; + let lastTime = null; + let accumulator = 0; + let droppedTicks = 0; + let renderCount = 0; + let historySignature = ""; + let notice = ""; + const nodes = [...document.querySelectorAll("[data-node]")]; + + function text(id, value) { + const element = $(id); + const next = String(value); + if (element.textContent !== next) element.textContent = next; + } + + function get(name) { return core.get(field[name]); } + function counter(name) { return get(name) >>> 0; } + function say(message) { text("live-status", message); } + function formatBytes(value) { return `${(value >>> 0).toLocaleString("ko-KR")} B`; } + function draft() { + return { mode: Number(document.querySelector('input[name="mode"]:checked').value), impulse: Number($("impulse").value), enabled: $("enabled").checked ? 1 : 0 }; + } + function isDirty() { + if (!applied) return false; + const next = draft(); + return next.mode !== applied.mode || next.impulse !== applied.impulse || next.enabled !== applied.enabled; + } + function draftChanged() { + text("impulse-output", $("impulse").value); + const dirty = isDirty(); + text("draft-status", dirty ? "아직 적용하지 않은 변경" : "현재 규칙과 같음"); + $("draft-status").parentElement.classList.toggle("dirty", dirty); + $("apply-button").disabled = !ready || !dirty; + if (dirty) text("apply-feedback", "아직 이전 규칙으로 움직입니다. 적용하면 위치와 시간은 그대로 이어집니다."); + else if (ready) text("apply-feedback", "바꾼 뒤 적용을 눌러주세요. 위치와 진행 시간은 이어집니다."); + } + function syncApplied() { + applied = { mode: get("mode"), impulse: get("impulse"), enabled: get("enabled") }; + document.querySelector(`input[name="mode"][value="${applied.mode}"]`).checked = true; + $("impulse").value = String(applied.impulse); + $("enabled").checked = Boolean(applied.enabled); + draftChanged(); + } + + function renderHistory() { + const entries = []; + for (let i = 0, count = Math.min(8, core.history_count()); i < count; i++) { + entries.push([0, 1, 2, 3].map((part) => core.history_get(i, part) >>> 0)); + } + const signature = JSON.stringify(entries); + if (signature === historySignature) return; + historySignature = signature; + const list = $("history-list"); + list.replaceChildren(); + if (entries.length === 0) { + const empty = document.createElement("li"); + empty.className = "empty-history"; + empty.textContent = "점프하거나 규칙을 적용하면 이곳에 기록됩니다."; + list.append(empty); + return; + } + for (const [tick, kind, detail, revision] of entries) { + const row = document.createElement("li"); + const time = document.createElement("span"); + time.className = "history-time"; + time.textContent = `${tick}t`; + const label = document.createElement("span"); + label.className = "history-label"; + const descriptions = { 1: "점프 허용", 2: reasonText[detail] || "점프 거절", 3: "규칙 적용", 4: "땅에 착지", 5: "장애물에 닿음", 6: "장애물을 지남" }; + label.textContent = `${descriptions[kind] || "상태 변경"} · v${revision}`; + row.append(time, label); + list.append(row); + } + } + + function renderDecision() { + const reason = get("reason"); + const result = $("decision-result"); + result.classList.toggle("accepted", reason === 1); + result.classList.toggle("rejected", reason > 1); + text("decision-result", reason === 0 ? "첫 점프를 기다리고 있어요." : reasonText[reason] || "입력을 확인해주세요."); + if (reason === 0) { + text("decision-explanation", "한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다."); + text("decision-meta", "아직 입력 없음"); + $("stale-decision").hidden = true; + } else { + const preY = core.decision_meta(2); + const preJumps = core.decision_meta(3); + const revision = core.decision_meta(1) >>> 0; + text("decision-explanation", `누른 순간: 높이 ${(preY / 100).toFixed(1)} px · 사용한 점프 ${preJumps}회. ${get("running") ? "이후에도 세계는 움직입니다." : "멈춘 상태에서 판단을 살펴보고 있어요."}`); + text("decision-meta", `${sourceText[get("source")] || "알 수 없는 입력"} · ${core.decision_meta(0) >>> 0} tick · v${revision}`); + $("stale-decision").hidden = revision === counter("revision"); + } + for (const node of nodes) { + const value = core.node_value(Number(node.dataset.node)); + if (value < 0) delete node.dataset.result; + else node.dataset.result = String(Boolean(value)); + const output = node.querySelector(".node-value"); + const next = value < 0 ? "대기" : value ? "예" : "아니요"; + if (output.textContent !== next) output.textContent = next; + } + } + + function render() { + if (!ready) return; + renderCount++; + const y = get("y") / 100; + const velocity = get("vy") / 100; + const tick = counter("tick"); + const running = Boolean(get("running")); + const obstacleHeight = get("obstacleHeight"); + const cameraY = Math.min(0, 180 - y); + const viewWidth = $("stage").clientWidth < 500 ? 500 : 960; + $("world").setAttribute("viewBox", `0 ${cameraY} ${viewWidth} 350`); + $("dino").setAttribute("transform", `translate(120 ${230 - y})`); + $("dino-shadow").setAttribute("rx", String(Math.max(10, 27 - y / 12))); + $("dino-shadow").setAttribute("opacity", String(Math.max(0.05, 0.15 - y / 1600))); + $("obstacle").setAttribute("transform", `translate(${get("obstacleX")} ${278 - obstacleHeight})`); + $("obstacle-body").setAttribute("height", String(obstacleHeight)); + $("obstacle-mark").setAttribute("d", `M11 10v${Math.max(0, obstacleHeight - 20)}m10-${Math.max(0, obstacleHeight - 27)}v${Math.max(0, obstacleHeight - 27)}`); + text("running-badge", running ? "실행 중" : "멈춰 있음"); + $("running-badge").classList.toggle("running", running); + text("run-button", running ? "일시정지" : "실행"); + $("run-button").setAttribute("aria-pressed", String(running)); + $("step-button").disabled = running; + text("stage-hint", cameraY < 0 ? "높이에 맞춰 시점이 공룡을 따라갑니다." : running ? "Space 또는 점프 버튼 · 멈춰서 규칙을 바꿔도 좋아요." : "일시정지 상태에서도 규칙과 입력을 확인할 수 있어요."); + text("state-height", `${y.toFixed(1)} px`); + text("state-velocity", `${velocity > 0 ? "+" : ""}${velocity.toFixed(1)} px/틱`); + text("state-ground", get("grounded") ? "땅 위" : "공중"); + text("state-jumps", `${counter("jumps")}회`); + text("state-distance", `${counter("distance").toLocaleString("ko-KR")} px`); + text("state-time", `${(tick / 60).toFixed(2)}초`); + text("state-tick", tick); + text("state-passed", counter("passed")); + text("state-hits", counter("hits")); + text("revision-badge", `v${counter("revision")}`); + text("applied-label", `적용 v${counter("revision")}`); + text("runtime-note", notice || (droppedTicks > 0 ? `화면 지연으로 ${droppedTicks.toLocaleString("ko-KR")}틱을 생략했습니다. 진행 시간은 실제 시간보다 느릴 수 있어요.` : "실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요.")); + text("scheduling-detail", `고정 60Hz · 한 화면 갱신당 최대 6틱 · 생략한 틱 ${droppedTicks.toLocaleString("ko-KR")}`); + text("bytes-linear", core.memory ? formatBytes(core.memory.buffer.byteLength) : "메모리 내보내기 없음"); + renderDecision(); + renderHistory(); + } + + function jump(source) { + if (!ready) return; + core.input(source); + render(); + say(reasonText[get("reason")] || "입력 처리 완료"); + } + + function setRunning(next) { + core.set_running(next ? 1 : 0); + accumulator = 0; + lastTime = null; + notice = ""; + render(); + say(next ? "실행했습니다." : "멈췄습니다. 규칙을 바꾸거나 한 프레임씩 살펴볼 수 있어요."); + } + + function frame(now) { + if (ready) { + if (get("running") && !document.hidden) { + if (lastTime !== null) { + accumulator += Math.max(0, now - lastTime); + const due = Math.floor(accumulator / frameMs); + if (due > 0) { + const ticks = Math.min(6, due); + droppedTicks += due - ticks; + accumulator -= due * frameMs; + core.advance(ticks); + render(); + } + } + lastTime = now; + } else { + lastTime = null; + accumulator = 0; + } + } + window.requestAnimationFrame(frame); + } + + $("rules-form").addEventListener("input", draftChanged); + $("rules-form").addEventListener("change", draftChanged); + $("rules-form").addEventListener("submit", (event) => { + event.preventDefault(); + if (!ready || !isDirty()) return; + const next = draft(); + if (core.apply_rules(next.mode, next.impulse, next.enabled) !== 0) { + text("apply-feedback", "적용하지 못했습니다. 점프 세기는 6~16이어야 합니다."); + say("규칙을 적용하지 못했습니다."); + return; + } + applied = next; + draftChanged(); + const feedback = `v${counter("revision")} 적용 완료. 위치·속도·진행 시간은 그대로 이어집니다.`; + text("apply-feedback", feedback); + render(); + say(feedback); + }); + $("run-button").addEventListener("click", () => { if (ready) setRunning(!get("running")); }); + $("step-button").addEventListener("click", () => { + if (!ready || get("running")) return; + core.step(); + render(); + say(`한 프레임 진행. 높이 ${(get("y") / 100).toFixed(1)} 픽셀.`); + }); + $("jump-button").addEventListener("click", () => jump(1)); + $("reset-button").addEventListener("click", () => { + if (!ready) return; + core.reset(); + core.set_running(0); + accumulator = 0; + lastTime = null; + droppedTicks = 0; + notice = "처음 상태로 돌아왔습니다. 멈춘 상태에서 다시 시작할 수 있어요."; + syncApplied(); + text("apply-feedback", "위치·진행 시간·규칙·기록을 초기 상태로 되돌렸습니다."); + render(); + say("세계와 규칙을 초기 상태로 되돌렸습니다."); + }); + document.addEventListener("keydown", (event) => { + if (!ready || (event.code !== "Space" && event.key !== " ") || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const target = event.target; + if (target instanceof Element && (target.isContentEditable || target.closest("input, textarea, select, button, a[href], summary, [contenteditable], [role=button]"))) return; + event.preventDefault(); + if (event.repeat) return; + jump(0); + }); + document.addEventListener("visibilitychange", () => { + if (!ready || !document.hidden) return; + const wasRunning = Boolean(get("running")); + core.set_running(0); + lastTime = null; + accumulator = 0; + if (wasRunning) { + notice = "다른 탭으로 이동해 자동으로 멈췄습니다. 실행을 누르면 이어집니다."; + render(); + say(notice); + } + }); + window.addEventListener("resize", render); + + Object.defineProperty(window, "pureDemo", { value: Object.freeze({ + snapshot() { + if (!ready) return Object.freeze({ ready: false }); + return Object.freeze({ ready: true, ...Object.fromEntries(Object.keys(field).map((name) => [name, get(name)])), draftDirty: isDirty(), droppedTicks, renderCount, wasmMemoryBytes: core.memory?.buffer.byteLength ?? null, logicalBytes: core.bytes(6), decisionTick: core.decision_meta(0), decisionRevision: core.decision_meta(1), decisionNodes: Object.freeze(nodes.map((node) => core.node_value(Number(node.dataset.node)))) }); + } + }), writable: false, configurable: false }); + + async function boot() { + try { + const response = await fetch("core.wasm", { cache: "no-store" }); + if (!response.ok) throw new Error(`core.wasm HTTP ${response.status}`); + const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), {}); + core = instance.exports; + for (const name of ["reset", "set_running", "advance", "step", "input", "apply_rules", "get", "node_value", "decision_meta", "history_count", "history_get", "bytes"]) { + if (typeof core[name] !== "function") throw new Error(`필요한 코어 함수가 없습니다: ${name}`); + } + core.reset(); + core.set_running(0); + ready = true; + $("rule-controls").disabled = false; + for (const id of ["run-button", "step-button", "jump-button", "reset-button"]) $(id).disabled = false; + $("workspace").setAttribute("aria-busy", "false"); + $("load-message").hidden = true; + syncApplied(); + ["rules", "state", "decision", "history", "engine", "graph", "total"].forEach((name, i) => text(`bytes-${name}`, formatBytes(core.bytes(i)))); + render(); + say("준비되었습니다. 멈춘 상태입니다. 점프 버튼을 눌러보세요."); + window.requestAnimationFrame(frame); + } catch (error) { + ready = false; + $("workspace").setAttribute("aria-busy", "false"); + $("load-message").hidden = true; + $("load-error").hidden = false; + text("running-badge", "실행기 없음"); + text("error-detail", error instanceof Error ? error.message : String(error)); + for (const control of document.querySelectorAll("button, #rule-controls")) control.disabled = true; + } + } + boot(); +})(); diff --git a/prototypes/live-rules/web/core.wasm b/prototypes/live-rules/web/core.wasm new file mode 100755 index 0000000..8c79903 Binary files /dev/null and b/prototypes/live-rules/web/core.wasm differ diff --git a/prototypes/live-rules/web/index.html b/prototypes/live-rules/web/index.html new file mode 100644 index 0000000..45ff700 --- /dev/null +++ b/prototypes/live-rules/web/index.html @@ -0,0 +1,101 @@ + + + + + + + Pure — 규칙 실험실 + + + + + + + +
+
+

작게 만들고, 직접 바꿔보기

뛰는 도중에, 규칙을 바꿔보세요.

같은 공룡, 같은 순간. 점프 규칙만 바꾸면 무엇이 달라질까요?

+

살아 있는 규칙
첫 번째 연습장

+
+ +
작은 실행기를 불러오고 있습니다…
+ + +
+
+

01연습장

불러오는 중
+
+ + 공룡 점프 연습장규칙을 적용하고 점프 버튼이나 스페이스 키로 움직여 보세요. 현재 상태는 연습장 아래에 표시됩니다. + + + + + + + + + + + + + + + PURE · RULE PLAYGROUND + +
일시정지 상태에서도 규칙과 입력을 확인할 수 있어요.
+
+
+
+ + +
+

실행하면 시간이 흐릅니다. 멈춘 뒤 한 프레임씩 살펴볼 수도 있어요.

+
+
높이—
세로 속도—
발 디딤—
사용한 점프—
진행 거리—
진행 시간—
+
+
지나간 장애물 0닿은 장애물 0tick 0
+
+ + +
+ +
+

03왜 그렇게 움직였나요?

마지막 점프 요청
+

첫 점프를 기다리고 있어요.

한 번 누르면 입력이 규칙을 통과하는 과정을 볼 수 있습니다.

아직 입력 없음
+ +
    +
  1. 01요청 도착대기
  2. 02입력 켜짐대기
  3. 03땅에 있음대기
  4. 04점프 여유대기
  5. 05방식에 맞음대기
  6. 06점프 허용대기
  7. +
+

요청과 입력 켜짐을 확인하고, 선택한 방식에 따라 ‘땅에 있음’ 또는 ‘점프 여유’를 판단합니다. 기록은 입력을 받은 순간의 상태입니다.

+

최근 변화 최대 8개 표시

  1. 점프하거나 규칙을 적용하면 이곳에 기록됩니다.
+
+ +
실험의 범위와 메모리 보기기술적인 내용은 여기에

무엇을 확인하는 실험인가요?

세계가 실행되는 동안 점프 규칙을 바꾸고, 상태를 유지하며, 입력 판단을 살펴보는 작은 시제품입니다. 물리 계산과 규칙 판단은 Zig로 작성한 동일한 WebAssembly 코어에서 처리합니다. 화면은 그 결과를 보여줍니다.

시간은 고정 60Hz 틱으로 진행합니다. 느린 화면에서는 한 번에 최대 6틱을 처리하며, 생략한 틱 수를 아래에 표시합니다. 실제 시간과 정확히 같다는 보장은 없습니다.

높이·속도·거리는 세계 좌표(px)로 표시합니다. 높은 점프에서는 시점이 공룡을 따라 위로 이동합니다. 화면 크기와 시점은 물리 계산을 바꾸지 않습니다.

아직 실행 전 · 생략한 틱 0

코어의 자료 구조 크기

코어가 보고한 논리 자료 크기 · 실제 프로세스 메모리와 다름
규칙—
세계 상태—
마지막 판단—
기록과 관리 정보—
엔진 전체 · 정렬 포함—
고정 규칙 그래프—
엔진 + 그래프 · 논리 합계—
WASM 선형 메모리 · 별도—

자료 구조 크기는 Zig 타입 배치 기준입니다. 고정 그래프는 컴파일 과정에서 코드에 통합될 수 있어, 이 수치를 실제 RAM 사용량으로 읽으면 안 됩니다. WASM 선형 메모리는 실제 할당된 별도 범위이며 위 합계와 더하지 않습니다. 브라우저·화면·코드·프로세스 전체 메모리(RSS)는 이 표에 포함되지 않습니다. M3 성능이나 최적화 우위를 입증하는 측정도 아닙니다.

+

+
+
Pure · 원하는 동작을 먼저, 근거는 남기면서.
+ + diff --git a/prototypes/live-rules/web/style.css b/prototypes/live-rules/web/style.css new file mode 100644 index 0000000..95207c8 --- /dev/null +++ b/prototypes/live-rules/web/style.css @@ -0,0 +1 @@ +:root{color-scheme:light;--page:#f5f4ee;--card:#fffefa;--ink:#27372b;--muted:#74806f;--line:#e3e6dc;--green:#356548;--green-light:#edf3e9;--amber:#a36a31;--amber-light:#faf0df;--radius:16px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--page)}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.46}a{color:inherit}button,input,a,summary,[tabindex]{-webkit-tap-highlight-color:transparent}button:focus-visible,input:focus-visible,a:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:3px solid #b37c39;outline-offset:4px}button,input{accent-color:var(--green)}[hidden]{display:none!important}.site-header{height:94px;max-width:1256px;padding:0 36px;margin:auto;display:flex;align-items:center;justify-content:space-between}.brand{display:inline-flex;align-items:center;gap:13px;font-size:19px;font-weight:750;text-decoration:none;letter-spacing:-.5px}.brand-mark{display:grid;place-items:center;width:35px;height:35px;background:var(--ink);color:var(--page);border-radius:9px;font:700 27px Georgia,serif;padding-bottom:4px}.brand-separator{margin:0 10px;color:#b1b7a9;font-weight:400}.brand-subtitle{font-size:14px;font-weight:500;letter-spacing:-.3px}.experiment-tag{display:inline-flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.status-dot,.tiny-dot{display:inline-block;width:6px;height:6px;background:#82a06e;border-radius:50%}main{max-width:1256px;margin:0 auto;padding:28px 36px 0}.intro{display:flex;align-items:center;justify-content:space-between;gap:32px;margin-bottom:30px}.eyebrow{font-size:11px;letter-spacing:1.25px;font-weight:650;color:var(--muted);margin:0 0 11px}h1{font-size:clamp(25px,2.6vw,34px);font-weight:650;line-height:1.35;letter-spacing:-1.3px;margin:0 0 11px}.intro-copy{color:var(--muted);font-size:14px;line-height:1.7;margin:0}.prototype-label{flex-shrink:0;text-align:right;color:var(--green);font-size:12px;font-weight:600;line-height:1.85;margin:0;border-left:1px solid var(--line);padding-left:35px}.prototype-label span{color:var(--muted);font-size:11px;font-weight:400}.notice,.load-error{border:1px solid var(--line);padding:16px 20px;border-radius:10px;margin-bottom:20px;font-size:13px;line-height:1.7;background:var(--card)}.load-error{background:#fff0e8;border-color:#e9c6b7;color:#7b392b}.load-error h2{font-size:17px}.load-error pre{overflow:auto;padding:12px;background:#fff9f3;border-radius:7px;font-size:12px}.error-detail{font-family:ui-monospace,monospace;font-size:11px;overflow-wrap:anywhere}.workbench{display:grid;grid-template-columns:minmax(0,1fr) 322px;gap:20px;align-items:start}.panel{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:22px 24px 19px}.panel-heading h2{font-size:14px;font-weight:650;margin:0;letter-spacing:-.3px;display:flex;align-items:center;gap:9px}.section-number{color:#9aa28f;font:11px ui-monospace,monospace;font-weight:400}.badge{font-size:10px;line-height:1;padding:6px 8px;background:#f0f1eb;color:#6e7967;border-radius:5px;font-variant-numeric:tabular-nums;white-space:nowrap}.badge.running{background:var(--green-light);color:var(--green)}.stage{position:relative;margin:0 14px;background:#f5f7f0;border:1px solid #e7ebdf;border-radius:10px;overflow:hidden;line-height:0}#world{display:block;width:100%;height:auto;min-height:230px}.stage-hint{position:absolute;top:16px;left:17px;right:15px;display:flex;align-items:center;gap:7px;color:#7a866f;font-size:10px;line-height:1.5}.tiny-dot{width:4px;height:4px;flex-shrink:0;background:#a5b496}.transport{display:flex;align-items:center;gap:9px;padding:20px 24px 0;flex-wrap:wrap}.transport-primary{display:flex;gap:7px}.button{border:1px solid transparent;border-radius:7px;line-height:1.25;font-size:12px;font-weight:550;padding:11px 15px;transition:background .15s,border-color .15s,transform .15s}.button:active:not(:disabled){transform:translateY(1px)}.button-dark{background:var(--ink);color:white;min-width:78px}.button-dark:hover:not(:disabled){background:#425544}.button-quiet{background:transparent;border-color:var(--line);color:var(--ink)}.button-quiet:hover:not(:disabled),.button-reset:hover:not(:disabled){background:#f2f3ec}.button-jump{margin-left:auto;background:var(--green-light);border-color:#dce6d5;color:var(--green);display:flex;align-items:center;gap:20px}.button-jump:hover:not(:disabled){background:#deead7}.button-jump kbd{font:9px ui-monospace,monospace;padding:2px 4px;border:1px solid #c7d5be;border-radius:3px;color:#6d865e}.button-reset{background:transparent;color:var(--muted);font-size:11px;padding-left:9px;padding-right:9px}.runtime-note{font-size:11px;color:var(--muted);line-height:1.7;margin:12px 24px 21px;min-height:19px}.state-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:23px 18px;padding:22px 24px;border-top:1px solid var(--line)}.state-grid>div{display:flex;flex-direction:column;gap:7px}.state-grid span{color:var(--muted);font-size:10px}.state-grid strong{font-size:16px;font-weight:550;font-variant-numeric:tabular-nums;letter-spacing:-.5px}.world-summary{display:flex;gap:22px;padding:14px 24px;border-top:1px solid var(--line);font-size:10px;color:var(--muted);background:#fcfcf7}.world-summary b{margin-left:5px;font-weight:550;color:#58694f;font-variant-numeric:tabular-nums}.tick-label{margin-left:auto;font-family:ui-monospace,monospace}.rules-panel .panel-heading{padding-bottom:22px}#rules-form{padding:0 23px}fieldset{border:0;padding:0;margin:0;min-width:0}.field-label{font-size:11px;font-weight:600;display:block;margin:0 0 12px}.mode-options{display:flex;flex-direction:column;gap:8px;margin-bottom:25px}.mode-option{position:relative;display:flex;align-items:center;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:8px;cursor:pointer;background:var(--card);min-width:0}.mode-option:has(input:checked){border-color:#8fa783;background:#f1f5ec}.mode-option:has(input:focus-visible){outline:3px solid #b37c39;outline-offset:3px}.mode-option input{position:absolute;opacity:0;width:1px;height:1px}.mode-option>span:first-of-type{flex:1}.mode-option b,.enabled-option b{display:block;font-size:12px;font-weight:550}.mode-option small,.enabled-option small{display:block;font-size:10px;line-height:1.6;color:var(--muted);margin-top:5px;word-break:keep-all}.radio-mark{width:13px;height:13px;border:1px solid #c2cbb9;border-radius:50%;display:grid;place-items:center;flex-shrink:0}.mode-option input:checked~.radio-mark{border-color:var(--green)}.mode-option input:checked~.radio-mark:after{content:"";background:var(--green);width:5px;height:5px;border-radius:50%}.force-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.force-heading .field-label{margin:0}.force-heading output{background:#f1f3ec;font-size:12px;font-variant-numeric:tabular-nums;padding:4px 9px;border:1px solid var(--line);border-radius:5px;min-width:34px;text-align:center}input[type=range]{display:block;width:100%;margin:14px 0 9px;cursor:pointer;height:16px}.range-labels{display:flex;justify-content:space-between;font-size:9px;color:#8a937f}.field-help{font-size:10px;color:var(--muted);margin:10px 0 21px;line-height:1.6}.enabled-option{display:flex;align-items:center;position:relative;cursor:pointer;gap:15px;border-top:1px solid var(--line);padding-top:19px}.enabled-option>span:first-child{flex:1}.enabled-option input{position:absolute;right:4px;opacity:0;width:28px;height:20px}.switch{width:31px;height:19px;background:#ced4c7;border:1px solid #c5cdbe;border-radius:20px;padding:2px;flex-shrink:0;transition:background .15s}.switch:after{content:"";display:block;width:13px;height:13px;background:white;border-radius:50%;box-shadow:0 1px 2px #0002;transition:transform .15s}.enabled-option input:checked+.switch{background:var(--green);border-color:var(--green)}.enabled-option input:checked+.switch:after{transform:translateX(12px)}.enabled-option input:focus-visible+.switch{outline:3px solid #b37c39;outline-offset:4px}.apply-area{margin-top:23px}.draft-line{display:flex;justify-content:space-between;gap:10px;font-size:10px;color:var(--muted);margin-bottom:9px}.draft-line.dirty #draft-status{color:var(--amber);font-weight:600}.button-apply{display:block;width:100%;background:var(--green);color:white;font-size:12px;padding:12px}.button-apply:hover:not(:disabled){background:#284e36}#apply-feedback{font-size:10px;line-height:1.7;min-height:35px;margin:10px 0 22px;color:var(--muted)}.try-card{background:#f4f5ee;border-top:1px solid var(--line);padding:20px 23px}.try-card .eyebrow{font-size:10px;letter-spacing:.8px;display:block;margin-bottom:11px}.try-card ol{font-size:11px;line-height:1.8;padding-left:17px;margin:0;color:#627058}.try-card li+li{margin-top:5px}.try-card b{font-weight:600}.try-card p{font-size:10px;color:#859079;line-height:1.65;margin:12px 0 0}.decision-panel{margin-top:20px}.muted-small{font-size:10px;color:var(--muted)}.decision-summary{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:0 24px 19px}.decision-result{margin:0 0 7px;font-size:18px;letter-spacing:-.6px;font-weight:550}.decision-result.accepted{color:var(--green)}.decision-result.rejected{color:var(--amber)}.decision-explanation{font-size:11px;color:var(--muted);line-height:1.7;margin:0}.decision-meta{font-size:10px;color:var(--muted);text-align:right;line-height:1.9;white-space:nowrap;font-variant-numeric:tabular-nums}.stale-note{font-size:11px;color:var(--amber);background:var(--amber-light);margin:0 24px 17px;padding:10px 12px;border-radius:6px;line-height:1.7}.decision-path{list-style:none;display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:12px;margin:0;padding:0 24px}.decision-path li{position:relative;border:1px solid var(--line);background:#fafbf6;border-radius:8px;padding:12px;min-width:0}.decision-path li:not(:last-child):after{position:absolute;right:-10px;top:50%;content:"›";color:#a4af9a;font-size:12px;line-height:0}.decision-path li[data-result=true]{background:#f0f5eb;border-color:#d4e2ca}.decision-path li[data-result=false]{background:#fbf6ed;border-color:#ebdfc9}.node-step{display:block;font:9px ui-monospace,monospace;color:#a2aa97;margin-bottom:13px}.decision-path strong{display:block;font-size:11px;font-weight:550;white-space:nowrap}.node-value{display:block;font-size:10px;color:#8b9580;margin-top:6px}.decision-path li[data-result=true] .node-value{color:var(--green)}.decision-path li[data-result=false] .node-value{color:var(--amber)}.path-caption{font-size:10px;line-height:1.7;color:#85907b;padding:0 24px;margin:12px 0 21px}.history-section{border-top:1px solid var(--line);padding:19px 24px 20px;display:grid;grid-template-columns:145px minmax(0,1fr);gap:25px}.history-section h3{font-size:11px;font-weight:550;margin:3px 0;line-height:1.6}.history-section h3 span{display:block;font-size:9px;color:#939d88;font-weight:400;margin-top:4px}.history-list{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 25px}.history-list li{display:flex;gap:10px;align-items:baseline;font-size:10px;line-height:1.7;color:#69755e;min-width:0}.history-list .history-time{font:9px ui-monospace,monospace;color:#9aa38f;flex-shrink:0}.history-list .history-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.history-list .empty-history{grid-column:1/-1;color:#8d9781}.research-panel{margin-top:20px;border:1px solid var(--line);border-radius:12px;background:#f0f1e9;overflow:hidden}.research-panel summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:12px;padding:19px 23px;font-size:12px;font-weight:500}.research-panel summary::-webkit-details-marker{display:none}.research-panel summary:before{content:"+";font:16px ui-monospace,monospace;color:#8a947e}.research-panel[open] summary:before{content:"−"}.summary-caption{margin-left:auto;font-size:10px;font-weight:400;color:#8d9781}.research-body{border-top:1px solid var(--line);display:grid;grid-template-columns:1fr 1fr;gap:60px;padding:24px}.research-body h2{font-size:12px;margin:0 0 14px;font-weight:600}.research-body p{font-size:11px;line-height:1.85;color:#74806a;margin:0 0 13px}.memory-table{width:100%;border-collapse:collapse;font-size:10px;line-height:1.6;font-variant-numeric:tabular-nums}.memory-table caption{text-align:left;font-size:9px;color:#8c967f;padding-bottom:9px}.memory-table th{text-align:left;font-weight:400;padding:5px 0;color:#6b7762}.memory-table td{text-align:right;color:#4f6249;padding:5px 0;font-family:ui-monospace,monospace}.memory-table .total-row th,.memory-table .total-row td{border-top:1px solid #d6ddcd;font-weight:600;padding-top:9px}.research-body .metric-note{font-size:10px;margin-top:12px;color:#8a947f}.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}footer{max-width:1256px;margin:0 auto;padding:30px 36px 35px;font-size:10px;color:#9ba48f;display:flex;align-items:center;gap:9px}footer span{color:#bdc4b4}@media(min-width:1100px){.state-grid{grid-template-columns:repeat(6,minmax(0,1fr));gap:10px}.state-grid strong{font-size:15px}.stage-hint{font-size:11px}}@media(max-width:900px){.site-header{height:77px;padding:0 24px}main{padding:22px 24px 0}.workbench{grid-template-columns:minmax(0,1fr) 286px;gap:15px}.panel-heading{padding:20px 19px 17px}#rules-form{padding:0 18px}.try-card{padding:18px}.transport{padding:17px 19px 0;gap:7px}.button{padding:10px 11px}.button-jump{gap:12px}.runtime-note{margin-left:19px;margin-right:19px}.state-grid{padding:19px}.world-summary{padding:13px 19px;gap:12px}.decision-path{gap:9px;padding:0 19px}.decision-path li{padding:10px 8px}.decision-path li:not(:last-child):after{right:-8px}.decision-path strong{font-size:10px}.decision-summary{padding-left:19px;padding-right:19px}.history-section{grid-template-columns:110px 1fr;padding-left:19px;padding-right:19px;gap:15px}.history-list{column-gap:14px}.intro{gap:20px}.prototype-label{display:none}.research-body{gap:32px}footer{padding-left:24px;padding-right:24px}}@media(max-width:700px){.site-header{height:70px;padding:0 18px}.brand{gap:9px;font-size:18px}.brand-separator{margin:0 6px}.brand-subtitle{font-size:12px}.experiment-tag{font-size:10px}main{padding:18px 16px 0}.intro{margin-bottom:24px}.eyebrow{font-size:10px}h1{font-size:25px;letter-spacing:-1px}.intro-copy{font-size:12px;word-break:keep-all}.workbench{grid-template-columns:1fr;gap:16px}.stage{margin:0 11px}#world{min-height:200px}.stage-hint{font-size:9px;left:11px;top:12px;max-width:280px}.transport{padding-left:17px;padding-right:17px;flex-wrap:nowrap;gap:6px}.button{font-size:11px;padding:11px 10px}.button-jump{gap:10px}.button-jump kbd{font-size:8px}.button-reset{font-size:10px;padding:10px 5px}.button-dark{min-width:69px}.state-grid{grid-template-columns:repeat(3,1fr);gap:20px}.state-grid strong{font-size:16px}.rules-panel .panel-heading{padding-bottom:19px}.mode-options{flex-direction:row;gap:9px;margin-bottom:21px}.mode-option{flex:1;padding:13px 11px;gap:7px}.mode-option b{font-size:11px}.mode-option small{font-size:9px}.radio-mark{width:12px;height:12px}.force-heading{margin-top:4px}.enabled-option{padding-top:15px}.apply-area{margin-top:21px}.try-card{padding:19px}.try-card li+li{margin-top:2px}.decision-panel{margin-top:16px}.decision-summary{align-items:flex-start;gap:15px}.decision-result{font-size:16px}.decision-explanation{font-size:10px}.decision-meta{font-size:9px}.decision-path{grid-template-columns:repeat(3,1fr);gap:9px}.decision-path li{padding:12px}.decision-path strong{font-size:11px}.decision-path li:nth-child(3):after{display:none}.node-step{margin-bottom:9px}.path-caption{padding:0 19px;font-size:9px}.history-section{display:block}.history-section h3{display:flex;justify-content:space-between;margin:0 0 13px}.history-section h3 span{margin:0}.history-list{grid-template-columns:1fr;gap:5px}.history-list li{font-size:10px}.stale-note{margin-left:19px;margin-right:19px;font-size:10px}.research-panel{margin-top:16px}.research-panel summary{padding:17px;font-size:11px}.summary-caption{font-size:9px}.research-body{grid-template-columns:1fr;gap:19px;padding:19px}.research-body>div+div{border-top:1px solid var(--line);padding-top:20px}footer{padding:24px 18px;font-size:9px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{transition:none!important}} diff --git a/prototypes/workbench/.gitignore b/prototypes/workbench/.gitignore new file mode 100644 index 0000000..cab7be7 --- /dev/null +++ b/prototypes/workbench/.gitignore @@ -0,0 +1,2 @@ +/build/ +/web/examples/ diff --git a/prototypes/workbench/CONTRACT.md b/prototypes/workbench/CONTRACT.md new file mode 100644 index 0000000..498129e --- /dev/null +++ b/prototypes/workbench/CONTRACT.md @@ -0,0 +1,575 @@ +# 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. + +The implementation now lives in explicit `Engine` instances in `engine.zig`. +`core.zig` retains the browser ABI and tests; `native.zig` exposes caller-owned +instances through the selected C module. Native prepared definitions, host input +ownership and conditional snapshots are documented in [native/README.md](native/README.md) +and [024](../../docs/research/024-native-app-instances.md). Existing scalar limits +remain. This does not make the selected scalar VM mandatory for every Pure domain. + +## 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/README.md b/prototypes/workbench/README.md new file mode 100644 index 0000000..e55f6a2 --- /dev/null +++ b/prototypes/workbench/README.md @@ -0,0 +1,354 @@ +# 빈 작업물에서 시작하는 Pure + +자신이 만들고 싶은 작은 동작을 작성하는 실험이다. 처음에는 코드·장면·입력이 +모두 비어 있다. 상태, 입력, 도형과 동작을 추가하는 데 실행기 소스를 고칠 필요가 없다. + +이 디렉터리는 브라우저·CLI 검증 호스트, 선택 가능한 네이티브 계산 모듈과 Linux GTK 작성 창이다. +Pure의 최종 목표는 앱 로직·네트워크·RPC·DB·UI를 다루는 독립 네이티브 애플리케이션 런타임이다. +UI는 그중 하나의 하위 체계이며, 현재 호스트는 전체 런타임이나 일반 네이티브 UI 스택이 아니다. +네이티브 코어 검사와 Wasm 크기도 네이티브 UI 완성이나 전체 메모리 효율의 근거가 아니다. +현재 개발 방향은 [로드맵](../../docs/research/010-roadmap.md)을 따른다. + +CLI에는 기존 작업물을 실제 네이티브 서버·바이트 저장소에 저장하고 새 실행기에서 복원하는 +`connect`·`push`·`pull`이 추가됐다. [서버 실행과 사용법](../connections/README.md)을 따른다. +브라우저 저장 버튼은 이 서버에 자동 연결되지 않는다. + +같은 `.pure`를 **Node·브라우저 없이 실행하는 네이티브 프로그램**으로 빌드하는 경로도 있다. +빌드 시 기존 컴파일러를 재사용하며, 입력·상태 스냅샷·저장·복원을 C 실행 코어에 연결했다. +실행 전용 터미널 프로그램, 선택형 GTK 작성 창, 선택형 TLS 피어 호스트가 있다. +[빌드와 사용법](native/README.md), [작성 창의 비용과 검증](../../docs/research/025-native-authoring-shell.md), +[피어 연결과 전체 비용](../../docs/research/026-secure-peer-link.md). + +## 열기 + +저장소 루트에서 다음 명령을 실행한다. Python 3가 필요하며, 포함된 Wasm으로 바로 열린다. + +```sh +python3 prototypes/workbench/manage.py serve --port 8766 +``` + +[제작 화면](http://127.0.0.1:8766)을 연다. 기존 Dino 실험은 8765 포트를 사용한다. +서버는 로컬 컴퓨터에서만 접속할 수 있다. + +## 직접 만드는 흐름 + +1. 먼저 만들고 싶은 작은 동작을 정한다. 무엇을 기억하고, 어떤 입력에 어떻게 바뀔지 정하면 된다. +2. 빈 편집기에 작성한다. 화면 아래 **언어 안내**의 조각을 조합해도 된다. +3. **코드 적용**을 누르면 도형과 선언한 입력 버튼이 생긴다. +4. 버튼을 눌러보거나 장면을 클릭한 뒤 지정한 키를 누른다. `on frame`이 있다면 **실행** 또는 **한 프레임**으로 시간을 진행한다. +5. 필요한 상태·입력·도형·반응을 더 작성하고 다시 적용한다. 같은 이름의 상태 값은 이어진다. +6. **.pure 저장**으로 코드를, **배치 저장**으로 코드와 현재 배치를 보관한다. 새로고침으로 복원되지 않으므로 저장이 필요하다. + +문법이 막힐 때 볼 수 있는 최소 예시다. 이 예시를 실행하는 것 자체가 제작 경험 평가를 뜻하지는 않는다. + +```text +state x = 80 +input right = ArrowRight +circle seed(x, 180, 20, "#7ed9ae") +on right.down { + x = x + 20 +} +``` + +새 반응을 추가할 때 원하는 표현을 찾을 수 있는지, 어디서 막히는지가 이번에 확인할 점이다. +관찰은 **하려던 일 / 실제로 일어난 일 / 기대했던 일** 정도면 된다. +완성된 카운터·충전·전환·직접 조작·세 도형 배치 예제는 별도로 선택해 불러오며 자동 적용하지 않는다. + +## 적용·정지·오류의 의미 + +- 편집 중인 코드와 적용한 코드는 별개다. 저장은 **미적용 변경까지 포함한 편집기 내용**을 저장한다. +- 적용은 같은 이름의 숫자 상태를 보존한다. 새 이름은 초깃값으로 시작하고 삭제한 이름은 사라진다. + 이름 변경을 자동으로 추적하지 않는다. 초깃값만 바꿨다면 **초깃값으로**를 눌러야 현재 값도 바뀐다. +- 문법·이름·용량 오류가 있는 수정은 기존 작업을 유지한 채 거절한다. **문제 위치로**에서 해당 행으로 이동한다. +- 누르는 중에는 적용할 수 없다. 모든 입력을 놓은 뒤 적용한다. +- 정지는 시간 진행만 멈춘다. 입력 버튼은 정지 중에도 작동한다. +- 계산 실패는 해당 이벤트의 모든 상태 변경을 취소한다. 마지막 실패 안내는 놓기 동작으로 사라지지 않으며, + 수정 적용 또는 초기화로 지워진다. 잘못된 도형 크기·표시 계산은 별도의 표시 오류다. +- 편집기나 버튼에 포커스가 있으면 키 입력을 장면에 전달하지 않는다. 장면을 클릭하면 키를 사용할 수 있다. + 창에서 포커스가 벗어나면 시간을 멈추고 누른 입력을 놓는다. 키 입력의 `.up`, 직접 조작 입력의 `.cancel` 동작이 실행될 수 있다. +- 실행은 프레임당 `dt = 1/60`이다. 화면 갱신마다 최대 6프레임을 진행하며, 밀린 나머지는 건너뛰고 횟수를 표시한다. + +## 표현할 수 있는 범위 + +상태는 숫자다. 입력은 키의 누름·놓음과 대상에 연결한 포인터 좌표, 시간은 `on frame`으로 선언한다. +출력은 사각형·원·숫자 글자이며 좌표·크기·표시 값을 상태 표현식에 연결한다. +색상은 `"#rrggbb"` 상수다. 화면 좌표는 640×360 기준이다. + +```text +on right.up { x = 80 } +on frame { + x = x + 30 * dt + if x > 600 { x = 0 } +} +box tile(x, 220, 40, 40, "#356548") +text label(40, 60, x, "x: ") +``` + +연산: `+ - * /`, 비교, `&& || !`, `min`, `max`, `clamp`. +논리 연산은 양쪽을 모두 계산한다. `if`의 조건은 한 번만 계산하고 대입은 위에서 아래로 실행한다. +같은 이벤트에 여러 반응을 선언하면 작성 순서대로 실행하며 한 이벤트 전체를 함께 성공·실패 처리한다. +이벤트의 `dt`는 시간 진행에서 1/60, 입력에서는 0이다. 표시 표현식에서는 `dt`를 쓰지 않는다. + +키: Space, Enter, 방향키, KeyA~KeyZ, Digit0~Digit9. 한 키는 하나의 입력에 연결한다. +주석은 `#`, 문장 구분은 줄바꿈 또는 세미콜론이다. + +한도: 상태 32개, 입력 8개, 도형/글자 24개, 반응 64개, 중간 명령 512개, +계산 스택 64칸, 소스 32,768 UTF-8 바이트. 반복문·일반 함수 호출·문자열 상태·동적 배열은 없다. 매개변수 하나를 받는 공유 반응 규칙은 지원한다. +충돌·중력·점프 등의 동작은 자동으로 제공되지 않는다. +정확한 인터페이스와 명령 형식은 [CONTRACT.md](CONTRACT.md)에 있다. + +## 도형을 직접 잡아 조작하기 + +예제 선택에서 **직접 잡아 이동 → 예제 불러오기 → 코드 적용**을 누른다. +[direct.pure](examples/direct.pure)는 잡은 위치를 유지한 이동, 영역 제한, +연결된 숫자 출력, 취소 시 복귀를 작성한 코드다. 좌우 방향키로도 위치를 바꿀 수 있다. + +```text +input drag = pointer(tile) +on drag.move { + x = clamp(event_x - grab_x, 20, 564) + y = clamp(event_y - grab_y, 100, 276) +} +``` + +전체 예제에서 `.down`은 잡은 위치와 시작 위치를 기억하고, `.up`은 마지막으로 표시한 위치를 +확정하며, `.cancel`은 시작 위치로 되돌린다. 이 동작은 자동 제공되는 물리가 아니다. +제한 값이나 취소 규칙을 바꾸고 다시 적용하면 동작도 바뀐다. +`event_x/event_y`는 포인터 반응 안에서만 쓸 수 있는 장면 좌표다. +서로 다른 대상은 동시에 조작할 수 있고, 같은 대상의 두 번째 접촉은 무시한다. +초점·캡처를 잃으면 `.cancel`로 끝난다. 하나의 이벤트가 실패하면 그 이벤트만 취소된다. + +터미널에서도 같은 정의에 `point drag down 90 170`, `point drag move 360 220`, +`point drag up 410 240`을 보내 재현할 수 있다. +기능 검증과 범위는 [직접 조작 보고서](../../docs/research/013-authored-pointer-input.md)에 기록했다. + +## 배치를 저장하고 다시 열기 + +**세 도형 배치 → 예제 불러오기 → 코드 적용**으로 시작한다. +세 도형을 따로 움직일 수 있고, 마지막으로 잡은 도형은 방향키로 10씩 조정한다. +시간 진행과 조작을 끝낸 뒤 **배치 저장**을 누르면 현재 상태까지 포함된 JSON을 내려받는다. +**파일 열기**로 이 파일을 선택하면 새 페이지에서도 같은 배치가 복원된다. + +- 배치 파일에는 적용된 코드, 현재 숫자 상태, 아직 적용하지 않은 편집 초안이 들어 있다. +- 복원은 저장된 코드를 실행 정의로 사용한다. 미완성 초안은 편집기에 남기며 자동 적용하지 않는다. +- 조작 중인 입력과 실행 시계는 복원하지 않는다. 열린 작업은 정지 상태로 시작한다. +- **초깃값으로**는 코드의 원래 초깃값으로 돌아간다. 저장 당시 배치로 돌아가려면 파일을 다시 연다. +- **.pure 저장 / 코드 JSON 저장**은 기존처럼 초안만 저장한다. 배치 저장과 구분한다. +- 잘못된 파일이나 읽는 동안 작업이 바뀐 파일은 기존 작업을 유지한 채 거절한다. + +터미널에서도 같은 파일을 쓸 수 있다. + +```sh +node prototypes/workbench/cli.mjs my-layout.pure.json +``` + +명령 `save-project new-layout.json`으로 현재 배치를 저장하고 `restore saved-layout.json`으로 +복원한다. 기존 파일을 덮어쓰지 않는다. 브라우저에서 저장한 미적용 초안도 다시 저장할 때 보존한다. + +## 같은 규칙을 여러 대상에 연결하기 + +**공유 규칙으로 배치** 예제는 앞의 세 도형 배치와 같은 작업을 공통 규칙으로 작성한다. +`record`는 필드의 기본값, `item`은 대상별 실제 값, `rule`은 재사용할 반응이다. + +```text +record Position { x=0; y=0 } +item green = Position(x=80,y=120) +rule move(t: Position) { t.x=event_x; t.y=event_y } +input drag = pointer(tile) +box tile(green.x,green.y,56,44,"#c9f08b") +on drag.move use move(green) +``` + +여러 입력을 같은 규칙에 연결해도 규칙의 실행 명령은 한 벌만 저장한다. +매개변수의 필드는 연결된 대상의 값을 직접 바꾼다. 같은 사건의 여러 반응 중 하나가 +실패하면 앞의 변경도 함께 취소한다. 대상·필드 선언 순서를 바꾸어 적용해도 +`green.x` 같은 이름으로 값을 유지하고, 배치 파일도 그 이름을 저장한다. + +규칙은 레코드 매개변수 하나를 받는다. 레코드당 필드는 1~8개이며 모든 대상의 필드는 +기존 상태 32개 한도에 포함된다. 실행 중 대상 생성·삭제는 아직 없다. +전체 작성물은 [shared-layout.pure](examples/shared-layout.pure), 결과와 한계는 +[공유 규칙 보고서](../../docs/research/015-shared-rules.md)에 있다. + +## 여러 도형을 하나의 포인터 입력으로 연결하기 + +**입력 묶어서 배치 → 예제 불러오기 → 코드 적용**으로 시작한다. + +```text +input drag = pointer { green_box: green; blue_box: blue } +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) +``` + +같은 record 타입의 대상을 묶고, 현재 잡은 대상의 필드를 공통 규칙에 전달한다. +도형을 늘려도 이 네 반응을 대상마다 복제하지 않는다. 서로 다른 도형은 동시에 +조작할 수 있고 하나를 놓거나 취소해도 다른 도형의 조작은 이어진다. +같은 대상은 한 그룹 연결만 가질 수 있다. 전체 연결 대상은 최대 24개이며 +상태 32개 등 기존 한도는 그대로다. 현재 예제의 키보드 선택 연결은 기존 방식이다. + +배치 파일은 이전과 같은 방식으로 저장·복원한다. 마지막 입력에는 대상 도형도 표시한다. +CLI에서는 `point-target drag green_box down 90 130`을 사용한다. +[전체 작성물](examples/routed-layout.pure), [구현 결과와 한계](../../docs/research/016-pointer-routing.md). + +## 일반 코드와 비교하기 + +[일반 JavaScript 비교 앱](http://127.0.0.1:8766/baseline.html)은 +[충전 예제](examples/charge.pure)와 같은 상태·출력 계산을 +[독립 JavaScript 모델](web/baseline.mjs)로 작성한 것이다. +Pure 컴파일러나 실행기를 가져오지 않는다. + +JS 모델은 고정된 앱이다. 일반 프로그램의 실행 중 교체나 상태 이전은 제공하지 않는다. +두 파일의 길이만으로 개발 생산성을 비교하지 않는다. 자세한 실행 비용과 범위는 +[연구 보고서 004](../../docs/research/004-authoring.md)에 있다. + +## 터미널에서 같은 작업 실행하기 + +Node.js가 필요하다. 검증에 사용한 버전은 24.20.0이다. + +```sh +node prototypes/workbench/cli.mjs my-work.pure +``` + +```text +down right +up right +frame 10 +show +load my-work.pure +reset +save saved-work.pure +quit +``` + +`load`는 같은 이름의 상태를 보존한다. `save`는 적용한 소스를 저장하고 기존 파일은 덮어쓰지 않는다. +경로에 공백이 있으면 명령 뒤에 경로 전체를 입력한다. 별도 따옴표는 붙이지 않는다. +브라우저와 터미널은 동일한 컴파일러·Wasm을 쓰지만 **각자 별도의 실행 상태**를 갖는다. + +## 계산 기록과 작업량 제한 + +표시 계산에 [기록을 버릴 수 있는 실행 계약](../../docs/research/009-bounded-display-integration.md)을 +연결했다. 모든 도형에 하나의 작업 한도를 적용한다. 한도를 넘으면 장면 전체를 +표시하지 않고 오류를 알린다. 그 전에 성공한 입력 이벤트의 상태는 유지된다. +기본값은 작업량 2,048, 캐시 꺼짐이다. 터미널에서 다음처럼 사용할 수 있다. + +```text +limits 32 8 2048 +show +costs +forget all +show +limits 32 8 1 +limits 32 8 2048 +``` + +`limits`의 숫자는 결과 캐시 개수·변경 기록 개수·표시 작업량이다. +`forget`은 `all`, `values`, `history`를 받는다. `costs`는 마지막 표시 계산의 +작업량·명령 수·캐시 적중을 보여준다. 한도가 부족하면 101 표시 오류가 나고, +충분히 늘리면 다시 계산한다. 파이프로 실행한 CLI는 중간 실패도 종료 코드 1에 남긴다. + +임베딩 API는 `createRuntime(wasmBytes, {cacheEntries:32, historyEntries:8, workLimit:2048})`, +`configureEvaluation`, `forgetEvaluation`, `evaluationStats`다. 브라우저 작성 화면에는 +이 설정을 위한 새 UI를 추가하지 않았다. 저장 공간 반납이나 앱 전체의 메모리·시간 +상한을 제공하는 기능은 아니다. 자세한 범위는 [계약](CONTRACT.md)을 따른다. + +## 빌드·검증 재현 + +Zig 0.15.2를 설치하고 저장소 루트에서 실행한다. 다른 경로의 Zig는 `--zig /path/to/zig`로 지정한다. + +```sh +python3 prototypes/workbench/manage.py build +node prototypes/workbench/compiler.test.mjs +node prototypes/workbench/runtime.test.mjs +node prototypes/workbench/baseline.test.mjs +node prototypes/workbench/cli.test.mjs +node prototypes/workbench/evaluation.test.mjs +node prototypes/workbench/workspace.test.mjs +node prototypes/workbench/presentation.test.mjs +node prototypes/workbench/routing.test.mjs +node prototypes/workbench/evaluation-check.mjs --output /tmp/pure-evaluation-new.json +node prototypes/workbench/workspace-check.mjs --output /tmp/pure-workspace-new.json +node prototypes/workbench/compare.mjs --output /tmp/pure-comparison-new.json +``` + +현재 빌드 명령은 네이티브 Zig 검사 37개도 실행한다. 전체 JS 검사는 +`node --test prototypes/workbench/*.test.mjs`로 실행하며 현재 87개다. +비교 파일은 덮어쓰지 않으므로 매번 새 출력 경로를 지정한다. +빌드는 `ReleaseSafe`, 디버그 정보 제거, 16 KiB 스택, 초기·최대 선형 메모리 64 KiB다. +산출물과 빌드 기록의 해시로 재현 대상을 확인할 수 있다. + +실제 브라우저 조작 시나리오는 [qa-browser.js](qa-browser.js)에 있다. +Playwright CLI 0.1.21의 `run-code`에 함수 전체를 전달하고, +마지막의 `window.__pureWorkbenchQAEvidence`를 후속 읽기로 수집했다. +대화상자·다운로드에서 CLI가 일찍 반환할 수 있으므로 반환 자체를 완료로 간주하지 않는다. +저장된 [브라우저 결과](results/2026-09-22-linux-aarch64/browser-checks.json)는 44개 확인을 포함한다. + +이 결과는 Linux aarch64에서 얻었다. M3 macOS, 네이티브 GUI·Metal, 센서 입력, +프로세스 전체 메모리와 실제 사용자의 제작 시간은 아직 측정하지 않았다. + +표시 계산의 첫 통합 기록은 +[2026-09-22-evaluation](results/2026-09-22-evaluation/verification.json)에 있다. +기존 44개 브라우저 확인은 이전 빌드의 기록이다. 이번 변경의 실제 브라우저 +확인 절차는 [qa-evaluation.js](qa-evaluation.js)다. +최신 빌드의 [브라우저 결과](results/2026-09-23-workspace/browser.json)는 12개 확인을 포함한다. +이 절차의 결과는 `window.__pureEvaluationEvidence`에서 읽는다. + +현재 빌드는 코드 교체 준비 공간과 계산 기록을 공유한다. 교체 준비 중에도 +기존 이벤트와 표시 계산은 실행할 수 있으며, 표시 계산은 기록 없이 재계산한다. +검사·비교 결과는 [2026-09-23-workspace](results/2026-09-23-workspace/verification.json)에 있다. +코어 고정 구조는 19,128 B로, 기록 기능을 넣기 전보다 증가한 전용 공간은 +56 B다. 공유 영역을 메모리 합계에 중복해서 더하지 않는다. + +현재 화면 갱신은 `present(target)`으로 계산 결과를 기존 DOM 요소에 전달한다. +매 입력마다 전체 장면 스냅샷이나 도형·상태·비용 요소를 다시 만들지 않는다. +CLI·검사용 `snapshot()`은 유지한다. [입력→출력 구현 보고서](../../docs/research/011-direct-presentation.md)에 +구조 변경, 추가 공간, 속도 측정의 한계를 기록했다. +직접 출력 통합 당시의 [52개 JS/CLI/Wasm 검사](results/2026-09-23-presentation/verification.json), +[브라우저 경로 비교](results/2026-09-23-presentation/browser.json), +[작성 흐름 검사](results/2026-09-23-presentation/browser-authoring.json), +[표시 한도 검사](results/2026-09-23-presentation/browser-limit.json)를 함께 보존했다. + +브라우저 비교는 Playwright CLI 0.1.21로 로컬 페이지를 연 세션에서 다음처럼 재실행한다. +이전 구현은 테스트 전용 응답 교체로 불러오며 현재 제품 파일은 바꾸지 않는다. + +```sh +python3 prototypes/workbench/qa-presentation.py --cli /path/to/playwright-cli.js --session pure-presentation --output /tmp/pure-presentation-new.json +``` + +후속으로 실행 입력과 코드 편집의 갱신 경로를 분리했다. 실행 중인 값이 바뀌어도 소스의 +줄 번호·크기·커서 정보를 다시 계산하지 않는다. 실제 소스 변경과 적용 상태는 계속 갱신한다. +[결과 보고서](../../docs/research/012-refresh-ownership.md), +[브라우저 비교](results/2026-09-23-refresh/browser.json), +[작성 흐름](results/2026-09-23-refresh/browser-authoring.json), +[표시 한도](results/2026-09-23-refresh/browser-limit.json)를 보존했다. +이 변경에서 런타임·컴파일러·Wasm은 그대로이며, 이전 단위 검사를 새 검사로 세지 않는다. + +```sh +python3 prototypes/workbench/qa-refresh.py --cli /path/to/playwright-cli.js --session pure-path --output /tmp/pure-refresh-new.json +``` + +## 실제 웹 레이아웃과 문서 편집 + +[문서 작업 화면](http://127.0.0.1:8766/document.html)은 일반 UTF-8 텍스트를 열고, +수정하고, 옆에서 읽고, `.txt`로 저장하는 작업이다. 기존 작업 화면에도 링크가 있다. + +- 넓은 화면에서는 경계를 잡거나 방향키·슬라이더로 두 패널의 비율을 조절한다. +- Esc·접촉 취소는 시작 비율로 돌아간다. 창 크기가 바뀌어도 조작 좌표를 다시 맞춘다. +- 좁은 화면에서는 작성·읽기가 위아래로 놓인다. 각 패널은 브라우저의 스크롤을 사용한다. +- **분할 규칙 수정**에서 `clamp(event_value,20,80)`의 제한을 더 좁혀 적용할 수 있다. +- 파일은 텍스트만 저장한다. 패널 비율과 규칙을 저장하는 별도 문서 형식은 만들지 않았다. + +이 화면은 직접 작성한 HTML 호스트다. Pure의 현재 DSL만으로 일반 웹 UI 전체를 +작성하게 된 것은 아니다. 레이아웃·텍스트·IME·선택을 VM에 복제하지 않고, 필요한 +숫자 조작만 전달한다. 파일은 2 MiB까지, UTF-8을 사용하며 저장 시 줄바꿈은 LF다. +현재 읽기 화면은 문서 편집 시 텍스트 전체를 갱신한다. 전체 앱의 메모리 절감이나 +큰 문서 성능 우위는 주장하지 않는다. [결과와 한계](../../docs/research/017-web-layout-boundary.md). + +```text +state ratio = 50 +input split = value +on split.change { ratio = clamp(event_value,20,80) } +``` + +웹 호스트 API는 `runtime.change('split',65,'range')`, CLI는 `change split 65`다. +숫자 값은 유한해야 하고 상태 배열에 자동 보관되지 않는다. 필요한 값만 규칙에서 저장한다. diff --git a/prototypes/workbench/baseline.test.mjs b/prototypes/workbench/baseline.test.mjs new file mode 100644 index 0000000..c9ef2de --- /dev/null +++ b/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/cli.mjs b/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/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/cli.test.mjs b/prototypes/workbench/cli.test.mjs new file mode 100644 index 0000000..f355d2d --- /dev/null +++ b/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/compare.mjs b/prototypes/workbench/compare.mjs new file mode 100644 index 0000000..ceadb0c --- /dev/null +++ b/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/compiler.test.mjs b/prototypes/workbench/compiler.test.mjs new file mode 100644 index 0000000..89b8c6c --- /dev/null +++ b/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/core.zig b/prototypes/workbench/core.zig new file mode 100644 index 0000000..9b5e213 --- /dev/null +++ b/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/engine.zig b/prototypes/workbench/engine.zig new file mode 100644 index 0000000..16907ef --- /dev/null +++ b/prototypes/workbench/engine.zig @@ -0,0 +1,700 @@ +//! 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"); +const root = @import("root"); +const fixed_definition = @hasDecl(root, "pure_definition"); +pub const Evaluation = @import("evaluation.zig").Evaluation; +pub const Tables = @import("evaluation.zig").Tables; + +// A fixed native definition may request exact capacities from its generated +// build root. Browser/authoring builds retain the full language limits. +pub const max_states = if (@hasDecl(root, "pure_max_states")) root.pure_max_states else 32; +pub const max_inputs = if (@hasDecl(root, "pure_max_inputs")) root.pure_max_inputs else 8; +pub const max_instructions = if (@hasDecl(root, "pure_max_instructions")) root.pure_max_instructions else 512; +pub const max_handlers = if (@hasDecl(root, "pure_max_handlers")) root.pure_max_handlers else 64; +pub const max_stack = 64; +pub const max_pointer_targets = if (@hasDecl(root, "pure_max_pointer_targets")) root.pure_max_pointer_targets else 24; +comptime { + if (max_states > 32 or max_inputs > 8 or max_instructions > 512 or + max_handlers > 64 or max_pointer_targets > 24) + @compileError("native profile exceeds the language limits"); +} +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, +}; +const shared_program: Program = if (fixed_definition) blk: { + const definition = root.pure_definition; + var program: Program = .{}; + program.state_count = definition.states_count; + program.input_count = definition.inputs_count; + program.instruction_count = definition.instructions_count; + program.handler_count = definition.handlers_count; + program.pointer_target_count = definition.pointer_targets_count; + program.routed_inputs = definition.routed_inputs; + for (definition.initials, 0..) |value, i| program.initials[i] = value; + for (definition.code, 0..) |inst, i| + program.instructions[i] = .{ .value = inst.value, .op = inst.op, .arg = inst.arg }; + for (definition.handlers_data, 0..) |handler, i| + program.handlers[i] = .{ .event = handler.event, .start = handler.start, + .length = handler.length, .parameter_base = handler.base, + .parameter_count = handler.count }; + for (definition.targets_data, 0..) |target, i| + program.pointer_targets[i] = .{ .input = target.input, .base = target.base, .count = target.count }; + break :blk program; +} else .{}; +// 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: if (fixed_definition) bool else Program = if (fixed_definition) false else .{}, + workspace: if (fixed_definition) void else Workspace = if (fixed_definition) {} else .{ .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 = .{}, + + fn program(self: *Engine) *const Program { + return if (comptime fixed_definition) &shared_program else &self.active; + } + + pub fn reuseTables(self: *Engine) ?*Tables { + if (comptime fixed_definition) return null; + return if (self.staging_open) null else &self.workspace.reuse; + } + + pub fn finishStaging(self: *Engine) void { + if (comptime fixed_definition) return; + 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 installFixed(self: *Engine, identity: *const [32]u8, + state_count: u32, input_count: u32, instruction_count: u32, + handler_count: u32, target_count: u32, restored: ?[*]const f64) u32 { + if (comptime !fixed_definition) return self.public(.invalid); + const definition = root.pure_definition; + if (!std.mem.eql(u8, identity, &definition.identity) or + state_count != definition.states_count or input_count != definition.inputs_count or + instruction_count != definition.instructions_count or + handler_count != definition.handlers_count or target_count != definition.pointer_targets_count) + return self.public(.invalid); + var next: [max_states]f64 = @splat(0); + for (0..state_count) |i| { + const value = if (restored) |values| values[i] else shared_program.initials[i]; + if (!std.math.isFinite(value)) return self.public(.non_finite); + next[i] = value; + } + self.active = true; + self.values = next; + self.staging_open = false; + self.staging_error = .ok; + self.evaluation.forget(null, 3); + if (restored != null) { + 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 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.program().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.program().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 = if (fixed_definition and !self.active) @splat(0) else self.program().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 { + var result: Execution = .{}; + if (comptime fixed_definition) { + if (!self.active) { + if (start != 0 or length != 0) return self.executionFail(result, .invalid, no_pc); + if (pure) return self.executionFail(result, .stack, 0); + return result; + } + } + const p = self.program(); + 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 (comptime fixed_definition) { + if (!self.active) { + if (event != 0 or context.point or context.scalar) { + 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 (dt != frame_dt) { + self.last_error = .bad_frame; + return self.public(.bad_frame); + } + self.tick +%= 1; + self.successful_events +%= 1; + return 0; + } + } + if (!self.validEvent(event, self.program().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.program().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.program().routed_inputs & (@as(u8, 1) << @as(u3, @intCast(self.eventInput(event)))) != 0; + if (context.target) |target| { + if (!routed or target >= self.program().pointer_target_count or + self.program().pointer_targets[target].input != self.eventInput(event)) + { + self.last_error = .bad_event; + return self.public(.bad_event); + } + route = self.program().pointer_targets[target]; + } else if (routed) { + self.last_error = .bad_event; + return self.public(.bad_event); + } + var pending = self.values; + for (self.program().handlers[0..self.program().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.program().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 { + if (comptime fixed_definition) if (cache != 0) return self.public(.capacity); + 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) + if (fixed_definition) @as(u32, 0) else @as(u32, @sizeOf(Tables)), + 7 => @sizeOf(Evaluation), + 8 => if (fixed_definition) 0 else @sizeOf(Tables), + 9 => @intFromBool(!self.staging_open), + 10 => if (fixed_definition) 0 else @sizeOf(Workspace) - @sizeOf(Pending), + else => 0, + }; + } + + pub fn get_state(self: *Engine, id: u32) f64 { + if (comptime fixed_definition) if (!self.active) return std.math.nan(f64); + return if (id < self.program().state_count) self.values[id] else std.math.nan(f64); + } + + pub fn info(self: *Engine, id: u32) u32 { + const loaded = if (fixed_definition) self.active else true; + return switch (id) { + 0 => self.tick, + 1 => self.public(self.last_error), + 2 => self.error_pc, + 3 => self.last_ops, + 4 => self.revision, + 5 => if (loaded) self.program().state_count else 0, + 6 => if (loaded) self.program().instruction_count else 0, + 7 => if (loaded) self.program().handler_count else 0, + 8 => if (loaded) self.program().input_count else 0, + 9 => self.successful_events, + else => 0, + }; + } + + pub fn layout(self: *Engine, id: u32) u32 { + if (comptime fixed_definition) { + if (!self.active and (id == 4 or id == 5 or id == 9)) return 0; + } + return switch (id) { + 0 => @sizeOf(Engine), + 1 => @sizeOf(Program), + 2 => @sizeOf(Instruction), + 3 => @sizeOf(Handler), + 4 => self.program().instruction_count * @sizeOf(Instruction) + self.program().handler_count * @sizeOf(Handler) + @as(u32, self.program().pointer_target_count) * @sizeOf(PointerTarget), + 5 => self.program().state_count * @sizeOf(f64), + 6 => if (fixed_definition) 0 else @sizeOf(Pending), + 7 => max_stack * @sizeOf(f64), + 8 => @sizeOf(PointerTarget), + 9 => self.program().pointer_target_count, + else => 0, + }; + } +}; diff --git a/prototypes/workbench/evaluation-check.mjs b/prototypes/workbench/evaluation-check.mjs new file mode 100644 index 0000000..bb3d616 --- /dev/null +++ b/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/evaluation.test.mjs b/prototypes/workbench/evaluation.test.mjs new file mode 100644 index 0000000..21a9590 --- /dev/null +++ b/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/evaluation.zig b/prototypes/workbench/evaluation.zig new file mode 100644 index 0000000..56081d9 --- /dev/null +++ b/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/examples/charge.pure b/prototypes/workbench/examples/charge.pure new file mode 100644 index 0000000..eac55c4 --- /dev/null +++ b/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/examples/counter.pure b/prototypes/workbench/examples/counter.pure new file mode 100644 index 0000000..ca5f18e --- /dev/null +++ b/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/examples/direct.pure b/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/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/examples/document.pure b/prototypes/workbench/examples/document.pure new file mode 100644 index 0000000..6d78f8c --- /dev/null +++ b/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/examples/layout.pure b/prototypes/workbench/examples/layout.pure new file mode 100644 index 0000000..41cf149 --- /dev/null +++ b/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/examples/routed-layout.pure b/prototypes/workbench/examples/routed-layout.pure new file mode 100644 index 0000000..8933c70 --- /dev/null +++ b/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/examples/shared-layout.pure b/prototypes/workbench/examples/shared-layout.pure new file mode 100644 index 0000000..38c001d --- /dev/null +++ b/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/examples/toggle.pure b/prototypes/workbench/examples/toggle.pure new file mode 100644 index 0000000..a29f38f --- /dev/null +++ b/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/manage.py b/prototypes/workbench/manage.py new file mode 100644 index 0000000..5bbc542 --- /dev/null +++ b/prototypes/workbench/manage.py @@ -0,0 +1,161 @@ +#!/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, peer=False): + if desktop and peer: + raise SystemExit('--desktop and --peer are separate optional hosts') + 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')] + prepared = folder / 'application.c' + profile = folder / 'fixed-profile.zig' + prepare = ['node', str(ROOT / 'native/prepare.mjs'), str(source.resolve()), str(prepared)] + if not desktop: + prepare.append(str(profile)) + subprocess.run(prepare, check=True) + library = folder / ('libpure-scalar.a' if desktop else 'libpure-scalar-fixed.a') + if desktop: + subprocess.run([compiler, 'build-lib', str(ROOT / 'native.zig'), '-O', 'ReleaseSafe', + '-fno-compiler-rt', f'-femit-bin={library}', *cache], check=True) + else: + subprocess.run([compiler, 'build-lib', '-O', 'ReleaseSafe', '-fno-compiler-rt', + '--dep', 'profile', '-Mroot=' + str(ROOT / 'native-fixed.zig'), + '-Mprofile=' + str(profile), f'-femit-bin={library}', *cache], check=True) + result = output.resolve() if output else folder / ('pure-desktop' if desktop else 'pure-peer' if peer 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/peer.c' if peer else 'native/main.c'), + *([ROOT / 'native/peer-wire.c', ROOT.parent / 'common/tls-link.c'] if peer else []), + 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'] + if not desktop: + flags.append('-DPURE_FIXED_DEFINITION=1') + desktop_flags = [] + peer_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}"']) + if peer: + peer_flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'openssl'], text=True)) + subprocess.run([cc, *flags, '-I' + str(ROOT / 'native'), *(str(p) for p in inputs), + '-lm', *desktop_flags, *peer_flags, *(['-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'], + 'scalarProfile': 'general-authoring' if desktop else 'fixed-definition', + 'zigRoot': str(ROOT / ('native.zig' if desktop else 'native-fixed.zig')), + 'cFlags': flags, + 'binary': str(result), 'binarySha256': sha(result), 'librarySha256': sha(library), + 'fixedProfileSha256': sha(profile) if not desktop else None, + 'preparedSha256': sha(prepared), 'desktop': desktop, 'desktopFlags': desktop_flags, + 'peer': peer, 'peerFlags': peer_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-fixed.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, authoring desktop, or peer 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') + n.add_argument('--peer', action='store_true', help='build the optional TLS 1.3 Pure-peer host') + 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, args.peer) + 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/native-fixed.zig b/prototypes/workbench/native-fixed.zig new file mode 100644 index 0000000..8ddd0df --- /dev/null +++ b/prototypes/workbench/native-fixed.zig @@ -0,0 +1,11 @@ +//! Fixed-definition native build root. The profile is generated from a +//! validated .pure definition; native.zig supplies the unchanged C ABI. +const profile = @import("profile"); +pub const pure_definition = profile; +pub const panic = @import("std").debug.no_panic; +pub const pure_max_states = profile.states; +pub const pure_max_inputs = profile.inputs; +pub const pure_max_instructions = profile.instructions; +pub const pure_max_handlers = profile.handlers; +pub const pure_max_pointer_targets = profile.pointer_targets; +comptime { _ = @import("native.zig"); } diff --git a/prototypes/workbench/native.zig b/prototypes/workbench/native.zig new file mode 100644 index 0000000..36a99e4 --- /dev/null +++ b/prototypes/workbench/native.zig @@ -0,0 +1,146 @@ +//! 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; +const fixed_definition = @hasDecl(@import("root"), "pure_definition"); + +// 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_install_fixed(instance: *Engine, identity: *const [32]u8, + state_count: u32, input_count: u32, instruction_count: u32, + handler_count: u32, target_count: u32, restored: ?[*]const f64) u32 { + return instance.installFixed(identity, state_count, input_count, instruction_count, + handler_count, target_count, restored); +} + +export fn pure_scalar_begin_program(instance: *Engine, state_count: u32, input_count: u32) u32 { + if (comptime fixed_definition) return 1; + return instance.begin_program(state_count, input_count); +} + +export fn pure_scalar_abort_program(instance: *Engine) void { + if (comptime fixed_definition) return; + instance.abort_program(); +} + +export fn pure_scalar_put_state(instance: *Engine, id: u32, initial: f64, old_id: i32) u32 { + if (comptime fixed_definition) return 1; + return instance.put_state(id, initial, old_id); +} + +export fn pure_scalar_put_instruction(instance: *Engine, op: u32, arg: u32, value: f64) u32 { + if (comptime fixed_definition) return 1; + return instance.put_instruction(op, arg, value); +} + +export fn pure_scalar_put_handler(instance: *Engine, event: u32, start: u32, length: u32) u32 { + if (comptime fixed_definition) return 1; + return instance.put_handler(event, start, length); +} + +export fn pure_scalar_bind_handler(instance: *Engine, handler: u32, base: u32, count: u32) u32 { + if (comptime fixed_definition) return 1; + return instance.bind_handler(handler, base, count); +} + +export fn pure_scalar_put_pointer_target(instance: *Engine, input: u32, base: u32, count: u32) u32 { + if (comptime fixed_definition) return 1; + return instance.put_pointer_target(input, base, count); +} + +export fn pure_scalar_bind_target_handler(instance: *Engine, handler: u32, count: u32) u32 { + if (comptime fixed_definition) return 1; + return instance.bind_target_handler(handler, count); +} + +export fn pure_scalar_put_restored_value(instance: *Engine, id: u32, value: f64) u32 { + if (comptime fixed_definition) return 1; + return instance.put_restored_value(id, value); +} + +export fn pure_scalar_commit_program(instance: *Engine) u32 { + if (comptime fixed_definition) return 1; + return instance.commit_program(); +} + +export fn pure_scalar_commit_restored_program(instance: *Engine) u32 { + if (comptime fixed_definition) return 1; + 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/native/README.md b/prototypes/workbench/native/README.md new file mode 100644 index 0000000..2485a2d --- /dev/null +++ b/prototypes/workbench/native/README.md @@ -0,0 +1,136 @@ +# 기존 .pure 작성물을 네이티브로 실행 + +기존 Workbench 문법을 그대로 준비해 C ABI의 독립 계산 인스턴스에서 실행한다. +입력과 저장·복원은 `prototypes/connections`의 실행 코어를 통과한다. 실행 전용 CLI와 +선택형 Linux GTK4 작성 창과 인증된 피어 연결 호스트가 있다. +빌드에는 Node·Zig 0.15.2·C17 컴파일러가 필요하다. 실행 전용 CLI의 실행에는 필요하지 않다. +선택형 작성 창은 소스를 적용할 때 Node·C 컴파일러가 필요하다. +현재 검증 환경은 Linux AArch64다. GTK 창은 실행 전용 프로그램에 들어가지 않는다. +고정 정의 CLI·피어는 검증된 `.pure`의 불변 프로그램을 한 번 링크하고 인스턴스마다 +가변 상태만 둔다. 새 정의를 사용하려면 다시 빌드한다. 작성 창과 브라우저는 정의 교체가 +가능한 전체 작성 용량을 유지한다. [반복 메모리와 실행물 전체 비용](../../../docs/research/028-shared-fixed-program.md)을 +구분해서 확인했다. +고정 실행 앱은 유휴 중 작업 슬롯을 예약하지 않는다. 입력·저장·복원 호출 동안만 +코어 슬롯을 스택에서 대여하고, 1,000개 실제 앱의 반복 RSS 절감과 추가 스택·코드 +비용을 [측정했다](../../../docs/research/049-idle-app-work-slots.md). + +저장소 루트에서: + +```sh +python3 prototypes/workbench/manage.py native prototypes/workbench/examples/routed-layout.pure --zig /path/to/zig +prototypes/workbench/build/native/pure-native /tmp/pure-native-work scene +``` + +프로그램에 입력할 명령: + +```text +point drag down green_box 90 130 +point drag move green_box 230 210 +point drag up green_box 230 210 +save +quit +``` + +같은 실행 파일을 같은 디렉터리·이름으로 다시 시작하고 `load`를 입력하면 저장한 상태를 +복원한다. 위 예제에서 `green.x=220`, `green.y=200`, `green.held=0`이다. 시작만으로 기존 상태를 +자동 채택하거나 덮어쓰지 않는다. 저장 성공 때 채택한 버전을 기준으로 다음 저장을 조건부 실행한다. +다른 실행기가 먼저 저장했으면 충돌 상태를 출력하고 로컬 값을 유지한다. + +다른 작성물도 `manage.py native MY.pure --output MY_APP`으로 빌드한다. 지원 명령은 +`show`, `key INPUT down|up`, `value INPUT NUMBER`, `point INPUT down|move|up|cancel TARGET X Y`, +`frame`, `reset`, `save`, `load`, `quit`이다. 입력 한 줄은 510 B까지다. +출력 JSON의 `status=0`은 성공이며 `scalar_error`는 작성된 계산의 실패 코드다. +명령 중 오류가 있었으면 종료 코드는 1, 시작 실패는 2다. 포인터 TARGET은 단일 입력에도 명시한다. +명령 인터페이스는 한 출처를 사용하고 C 호스트 API는 대상별 소유자·키별 출처를 구분한다. + +## 선택형 네이티브 작성 창 + +Linux에서 GTK4 개발 헤더·라이브러리와 `pkg-config`를 설치한 뒤 빌드한다. 작성 창의 +실시간 소스 적용에는 Node와 C 컴파일러도 실행 시 필요하다. CLI 실행에는 둘 다 필요 없다. + +```sh +python3 prototypes/workbench/manage.py native prototypes/workbench/examples/routed-layout.pure \ + --desktop --zig /path/to/zig +prototypes/workbench/build/native/pure-desktop /tmp/pure-native-work scene \ + prototypes/workbench/examples/routed-layout.pure +``` + +왼쪽에서 `.pure`를 편집하고 **Apply source**를 누른다. 성공하면 같은 이름의 숫자 상태를 +유지하고 소스 파일을 저장한다. 실패하면 편집 중인 텍스트는 남지만 이전 실행 정의와 저장된 +소스는 유지된다. 창을 다시 열면 소스를 준비·적용한다. 오른쪽 화면을 눌러 키를 전달하고 +도형을 잡아 이동한다. **Save state**와 **Load state**는 소스 파일과 별개의 조건부 스냅샷이다. +소스를 바꾸면 이전 정의의 스냅샷은 자동 복원되지 않는다. 새 정의를 적용한 뒤 상태를 다시 +저장하면 새 정의의 스냅샷이 된다. 수치 입력은 오른쪽 아래 입력창에서 숫자를 입력하고 Enter를 누른다. + +GPU가 없는 Xvfb 검증에서는 GTK의 Cairo 렌더러와 GPU 탐색 비활성화를 사용했다. + +```sh +GSK_RENDERER=cairo GDK_DISABLE=gl,vulkan \ + prototypes/workbench/build/native/pure-desktop /tmp/pure-native-work scene \ + prototypes/workbench/examples/routed-layout.pure +``` + +이 설정은 모든 호스트의 기본값이 아니다. 이 환경의 유휴 GTK 작성 창은 약 36 MiB RSS, +기본 소프트웨어 GPU 경로는 약 174 MiB였으며 CLI는 약 1.5 MiB였다. **편집 도구 전체를 +마이크로 런타임 크기라고 부르지 않는다.** [실제 창 검증·전체 비용](../../../docs/research/025-native-authoring-shell.md). + +`scalar.h`는 호출자가 제공한 메모리의 명시적인 인스턴스 API다. `plan.h`는 빌드 시 준비한 +읽기 전용 데이터, `app.h`는 같은 코어에서 입력·스냅샷·저장을 연결하는 호스트다. +고정 CLI·피어에서는 `plan.h`의 명령·반응 포인터가 비어 있으며, 해당 데이터는 Zig의 +공유 프로그램에서 읽는다. 고정 실행물의 정의 staging 및 선택형 평가 캐시는 사용할 수 없다. +`prepare.mjs`는 기존 컴파일러를 재사용하며 새 런타임 파서를 추가하지 않는다. +준비된 명령을 해석하는 계산 모듈이며, 모든 앱 연산을 기계어로 직접 생성하는 컴파일러는 아니다. +작성 창의 새 정의 설치도 실행 코어의 작업 접수·완료를 거친다. `PURE_NATIVE_AUTHORING`은 +작성 창에만 링크되어 CLI의 코드 크기와 동적 의존성을 늘리지 않는다. + +## 선택형 인증 피어 연결 + +OpenSSL 개발 패키지와 `pkg-config`가 있는 Linux에서 빌드한다. 실행 전용 CLI에는 +OpenSSL을 연결하지 않는다. 같은 `.pure` 정의로 만든 피어 서버와 클라이언트가 필요하다. + +```sh +python3 prototypes/workbench/manage.py native prototypes/workbench/examples/routed-layout.pure \ + --peer --zig /path/to/zig +prototypes/workbench/build/native/pure-peer serve 127.0.0.1 8443 /tmp/pure-peer-state scene \ + CA.pem server.pem server.key pure-client +prototypes/workbench/build/native/pure-peer call 127.0.0.1 8443 localhost \ + CA.pem client.pem client.key +``` + +서버 인증서는 `localhost` DNS SAN과 `serverAuth`, 클라이언트 인증서는 `pure-client` +DNS SAN과 `clientAuth`가 필요하다. 양쪽 인증서는 지정한 CA의 서명을 받아야 한다. +실제 검증용 일회성 인증서를 만들고 Python 표준 TLS 클라이언트와 상호운용성·실패 경로를 +검사하는 재현 명령은 다음과 같다. 인증서는 임시 디렉터리에서만 사용하고 삭제한다. + +```sh +python3 prototypes/workbench/native/peer-check.py prototypes/workbench/build/native/pure-peer +``` + +클라이언트 명령은 `state`, `save`, `load`, `frame`, `reset`, `key NAME down|up`, +`value NAME NUMBER`, `point NAME down|move|up|cancel TARGET X Y`, `quit`이다. +서버는 시작 시 기존 저장 상태를 복원하고, 각 요청의 기대 버전을 확인한다. 연결이 끊긴 뒤 +입력 소유권을 해제한다. 응답을 잃은 부작용 작업은 자동 재시도하지 않는다. +정의 identity가 다르면 Pure 클라이언트가 연결 직후 거절한다. 연결은 현재 한 번에 한 +클라이언트만 처리한다. 서버와 클라이언트 모두 인증 실패 시 평문으로 낮추지 않는다. +프로토콜 길이·크기·전체 호스트 RSS와 한계는 [026 보고서](../../../docs/research/026-secure-peer-link.md)에 있다. + +현재 저장 스냅샷은 이 실행물의 준비 정의에 묶여 있다. 정의를 바꾼 뒤 다른 정의의 스냅샷을 +묵시적으로 적용하지 않는다. 작성 창에는 실시간 정의 교체가 있지만 CLI의 실시간 소스 편집과 +기존 JSON 프로젝트 변환은 미구현이다. 현재 보기는 고정된 640×360 논리 화면을 확대·축소하며 +범용 네이티브 레이아웃 엔진이나 macOS 창 백엔드는 아니다. + +일반·C 호스트 sanitizer 검증 (`prototypes/workbench` 기준): + +```sh +node native/check.mjs +node native/check.mjs --sanitize +node native/check.mjs --fixed --sanitize --zig /path/to/zig +node --test *.test.mjs +``` + +첫 세 명령은 현재 소스에서 라이브러리를 직접 빌드하고 기존 Wasm을 사용하며, 실제 자식 +프로세스와 파일 저장이 필요하다. Zig 0.15.2가 `PATH`에 없다면 각 명령에 +`--zig /path/to/zig`를 준다. `--fixed`는 작성물별 공유 불변 프로그램을 빌드해 같은 동작을 +대조한다. Zig 오브젝트는 ReleaseSafe이며 C 호스트만 ASan/UBSan으로 계측한다. +[전체 용량 기준](../../../docs/research/024-native-app-instances.md), +[공유 고정 정의 결과](../../../docs/research/028-shared-fixed-program.md). diff --git a/prototypes/workbench/native/app-memory.c b/prototypes/workbench/native/app-memory.c new file mode 100644 index 0000000..a1fd0f0 --- /dev/null +++ b/prototypes/workbench/native/app-memory.c @@ -0,0 +1,87 @@ +#define _POSIX_C_SOURCE 200809L +/* Linux-only whole-app evidence: many independent native app instances, each + * exercising the execution core, scalar program and durable state path. */ +#include "app.h" +#include +#include +#include +#include +#include + +extern const PureScalarPlan pure_app_plan; + +static long rss_kib(void) { + FILE *file = fopen("/proc/self/status", "r"); + if (!file) return -1; + char line[256]; + long result = -1; + while (fgets(line, sizeof(line), file)) + if (strncmp(line, "VmRSS:", 6) == 0) { + if (sscanf(line + 6, "%ld", &result) != 1) result = -1; + break; + } + fclose(file); + return result; +} + +int main(void) { + enum { COUNT = 1000 }; + char directory[] = "/tmp/pure-app-memory-XXXXXX"; + assert(mkdtemp(directory)); + long before = rss_kib(); + assert(before >= 0); + PureNativeApp *apps = calloc(COUNT, sizeof(*apps)); + void **scalar_memory = calloc(COUNT, sizeof(*scalar_memory)); + assert(apps && scalar_memory); + size_t scalar_bytes = pure_scalar_bytes(); + size_t completed_actions = 0, completed_transfers = 0; + for (size_t i = 0; i < COUNT; ++i) { + scalar_memory[i] = malloc(scalar_bytes); + assert(scalar_memory[i]); + char key[32]; + snprintf(key, sizeof(key), "doc%04zu", i); + assert(pure_native_app_init(&apps[i], &pure_app_plan, scalar_memory[i], + scalar_bytes, directory, key, 4096) == PURE_OK); + assert(pure_native_app_action(&apps[i], (PureAction){.phase = PURE_RESET}).status == PURE_OK); + ++completed_actions; +#ifndef PURE_APP_MEMORY_PERSISTENT_WORK + assert(!apps[i].execution.work && !apps[i].execution.capacity); +#endif + } + long initialized = rss_kib(); + assert(initialized >= before); + for (size_t i = 0; i < COUNT; ++i) { + assert(pure_native_app_save(&apps[i]).status == PURE_OK); + ++completed_transfers; + assert(apps[i].stored_revision == 1); + assert(!apps[i].execution.active && !apps[i].execution.retained); +#ifndef PURE_APP_MEMORY_PERSISTENT_WORK + assert(!apps[i].execution.work && !apps[i].execution.capacity); +#endif + } + long saved = rss_kib(); + assert(saved >= before); + for (size_t i = 0; i < COUNT; i += 249) { + assert(pure_native_app_restore(&apps[i]).status == PURE_OK); + ++completed_transfers; + } + for (size_t i = 0; i < COUNT; ++i) { + assert(!apps[i].execution.active && !apps[i].execution.retained); + char name[128]; + snprintf(name, sizeof(name), "%s/obj-%s", directory, apps[i].document); + assert(unlink(name) == 0); + free(scalar_memory[i]); + } + char lock[128]; + snprintf(lock, sizeof(lock), "%s/.writer.lock", directory); + assert(unlink(lock) == 0 && rmdir(directory) == 0); + printf("{\"apps\":%u,\"app_controller_bytes\":%zu,\"scalar_bytes\":%zu," + "\"rss_before_kib\":%ld,\"rss_initialized_kib\":%ld,\"rss_saved_kib\":%ld," + "\"rss_initialized_delta_kib\":%ld,\"rss_saved_delta_kib\":%ld," + "\"completed_actions\":%zu,\"completed_transfers\":%zu}\n", + COUNT, sizeof(*apps), scalar_bytes, before, initialized, saved, + initialized - before, saved - before, completed_actions, completed_transfers); + free(scalar_memory); + free(apps); + return 0; +} diff --git a/prototypes/workbench/native/app.c b/prototypes/workbench/native/app.c new file mode 100644 index 0000000..e53b8a5 --- /dev/null +++ b/prototypes/workbench/native/app.c @@ -0,0 +1,260 @@ +#include "app.h" +#include "../../connections/store-port.h" +#include "../../connections/store.h" +#include +#include +#include + +_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 void *definition, const char *key, + size_t available, PureResource *out, int64_t *revision) { + PureNativeApp *app = context; + (void)definition; + if (!strcmp(key, "load")) { + PureHost store = pure_store_host(app->directory); + return store.read(store.context, &connections, app->document, 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 (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 void *definition, const char *key, + int64_t expected, PureSpan bytes, PureSpan *reply, + int64_t *revision) { + PureNativeApp *app = context; + (void)definition; + *reply = (PureSpan){0}; + 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, NULL, 0, NULL, 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) { + PureWork work[1]; + if (!pure_runtime_bind(&app->execution, work, 1)) + return (PureOutcome){.status = PURE_BUSY}; + PureTicket ticket; + PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_WRITE, "event", app->revision, sizeof(input), &ticket); + PureOutcome result = {.status = status}; + if (status == PURE_OK) { + memcpy(pure_runtime_ingress(ticket, NULL), &input, sizeof(input)); + pure_runtime_ready(ticket); + result = finish(app, ticket); + pure_runtime_release(ticket); + } + result.body = (PureSpan){0}; + if (!pure_runtime_bind(&app->execution, NULL, 0)) abort(); + return result; +} + +static PureOutcome transfer(PureNativeApp *app, bool restore) { + PureWork work[2]; + if (!pure_runtime_bind(&app->execution, work, 2)) + return (PureOutcome){.status = PURE_BUSY}; + PureTicket source, destination; + PureResult status = pure_runtime_accept(&app->execution, PURE_INPUT_READ, restore ? "load" : "snapshot", 0, 0, &source); + PureOutcome result = {.status = status}; + if (status != PURE_OK) goto done; + 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) { result = (PureOutcome){.status = status}; goto done; } + 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); +done: + /* Bodies are consumed internally before this API returns. */ + result.body = (PureSpan){0}; + if (!pure_runtime_bind(&app->execution, NULL, 0)) abort(); + 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; + PureWork work[1]; + if (!pure_runtime_bind(&app->execution, work, 1)) 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) { + memcpy(pure_runtime_ingress(ticket, NULL), &candidate, sizeof(candidate)); + pure_runtime_ready(ticket); + PureOutcome result = finish(app, ticket); + pure_runtime_release(ticket); + status = result.status; + } + if (!pure_runtime_bind(&app->execution, NULL, 0)) abort(); + return status; +} +#endif diff --git a/prototypes/workbench/native/app.h b/prototypes/workbench/native/app.h new file mode 100644 index 0000000..dd498cc --- /dev/null +++ b/prototypes/workbench/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; + 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. + * Idle instances retain no work slots; operations bind caller-stack slots. */ +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/native/check.mjs b/prototypes/workbench/native/check.mjs new file mode 100644 index 0000000..3b021f1 --- /dev/null +++ b/prototypes/workbench/native/check.mjs @@ -0,0 +1,178 @@ +#!/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 fixed = args.includes('--fixed'); +const zigIndex = args.indexOf('--zig'); +const zig = zigIndex >= 0 ? args[zigIndex + 1] : process.env.ZIG || 'zig'; +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 = join(temporary, 'general.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', + '-DPURE_RUNTIME_METRICS', + ...(sanitized ? ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] : []), + ...(fixed ? ['-DPURE_FIXED_DEFINITION=1'] : []), '-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 { + successful(zig, ['build-lib', resolve(root, 'native.zig'), '-O', 'ReleaseSafe', + '-fno-compiler-rt', '-femit-bin=' + library, '--cache-dir', resolve(root, 'build/cache'), + '--global-cache-dir', resolve(root, 'build/global-cache')]); + 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 sizeSource = join(temporary, 'size.c'); + await writeFile(sizeSource, '#include "scalar.h"\n#include \nint main(void) { printf("%zu\\n", pure_scalar_bytes()); }\n'); + const generalSizeBinary = join(temporary, 'general-size'); + successful(compiler, [...flags, sizeSource, library, '-lm', '-o', generalSizeBinary]); + const generalInstanceBytes = Number(successful(generalSizeBinary, []).trim()); + 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']], + ['layout', await definition('layout'), ['point drag1 down tile1 90 130', 'point drag1 move tile1 230 210', + 'point drag1 up tile1 230 210', 'key right down', 'key right up', + 'point drag3 down tile3 460 230', 'point drag3 cancel tile3 510 270']], + ['shared-layout', await definition('shared-layout'), ['point drag_green down green_box 90 130', + 'point drag_green move green_box 230 210', 'point drag_green up green_box 230 210', + 'key right down', 'key right up', 'point drag_amber down amber_box 460 230', + 'point drag_amber cancel amber_box 510 270']], + ['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); + const profile = join(temporary, name + '.zig'); + successful(process.execPath, [resolve(root, 'native/prepare.mjs'), sourcePath, prepared, + ...(fixed ? [profile] : [])]); + let caseLibrary = library; + if (fixed) { + caseLibrary = join(temporary, name + '.a'); + successful(zig, ['build-lib', '-O', 'ReleaseSafe', '-fno-compiler-rt', '--dep', 'profile', + '-Mroot=' + resolve(root, 'native-fixed.zig'), '-Mprofile=' + profile, + '-femit-bin=' + caseLibrary, '--cache-dir', resolve(root, 'build/cache'), + '--global-cache-dir', resolve(root, 'build/global-cache')]); + } + successful(compiler, [...flags, prepared, main, ...objects, caseLibrary, '-lm', '-o', binary]); + const sizeBinary = join(temporary, name + '-size'); + successful(compiler, [...flags, sizeSource, caseLibrary, '-lm', '-o', sizeBinary]); + const instanceBytes = Number(successful(sizeBinary, []).trim()); + assert(Number.isSafeInteger(instanceBytes) && instanceBytes > 0 && instanceBytes <= generalInstanceBytes); + 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') { + if (fixed) { + const capacityUnit = join(temporary, 'fixed-capacity'); + successful(compiler, [...flags, prepared, resolve(root, 'native/test-fixed.c'), + resolve(root, 'native/plan.c'), caseLibrary, '-lm', '-o', capacityUnit]); + unitResult = JSON.parse(successful(capacityUnit, [])); + } else { + 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), caseLibrary, '-lm', '-o', unit]); + unitResult = JSON.parse(successful(unit, [])); + } + } + cases.push({name, commands: commands.length, failed_events: failures, states_and_views_match: true, + instance_bytes: instanceBytes, fixed_profile_sha256: fixed ? hash(await readFile(profile)) : null, + 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, 'native-fixed.zig'), + resolve(root, 'manage.py'), ...['scalar.h','plan.h','plan.c','app.h','app.c','main.c','prepare.mjs','test.c','test-fixed.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, + profile: fixed ? 'fixed-definition' : 'general-authoring', general_instance_bytes: generalInstanceBytes, + 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), + general_instance_bytes: generalInstanceBytes, + instance_bytes: Object.fromEntries(cases.map(c => [c.name, c.instance_bytes])), + units:unitResult, sanitized_c_host:sanitized, profile:result.profile})); +} finally { + await rm(temporary, {recursive:true, force:true}); +} diff --git a/prototypes/workbench/native/check_app_memory.py b/prototypes/workbench/native/check_app_memory.py new file mode 100644 index 0000000..713978e --- /dev/null +++ b/prototypes/workbench/native/check_app_memory.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Compare complete 1,000-instance native app memory, with real actions and saves.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import statistics +import subprocess +import tempfile + +WORKBENCH = Path(__file__).resolve().parents[1] +REPO = WORKBENCH.parents[1] +NATIVE = WORKBENCH / 'native' +CONNECTIONS = REPO / 'prototypes/connections' +PREPARED = WORKBENCH / 'build/native/application.c' +PROFILE = WORKBENCH / 'build/native/fixed-profile.zig' +LIBRARY = WORKBENCH / 'build/native/libpure-scalar-fixed.a' +BASELINE = WORKBENCH / 'results/2026-09-24-native-desktop/sources/native' + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def build(cc, directory, name, app, header, legacy, metrics=False): + binary = directory / name + command = [cc, '-std=c17', '-O2', + '-ffile-prefix-map=' + str(directory) + '=/pure-app-memory', + '-Wall', '-Wextra', + '-Wpedantic', '-Werror', '-DPURE_FIXED_DEFINITION=1'] + if legacy: + command.append('-DPURE_APP_MEMORY_PERSISTENT_WORK=1') + if metrics: + command.append('-DPURE_RUNTIME_METRICS') + command += ['-I' + str(header), '-I' + str(NATIVE), str(PREPARED), + str(directory / 'app-memory.c'), str(app), str(NATIVE / 'plan.c')] + command += [str(CONNECTIONS / name) for name in + ('runtime.c', 'evaluate.c', 'store.c', 'store-port.c')] + command += [str(LIBRARY), '-lm', '-o', str(binary)] + subprocess.run(command, check=True) + sections = subprocess.check_output(['size', str(binary)], text=True).splitlines()[1].split() + samples = [] + for _ in range(3): + sample = json.loads(subprocess.check_output([str(binary)], text=True, timeout=30)) + assert sample['apps'] == sample['completed_actions'] == 1000 + assert sample['completed_transfers'] == 1005 + samples.append(sample) + return {'binary_sha256': digest(binary), 'app_source_sha256': digest(app), + 'app_header_sha256': digest(header / 'app.h'), + 'text_bytes': int(sections[0]), 'data_bytes': int(sections[1]), + 'app_controller_bytes': samples[0]['app_controller_bytes'], + 'scalar_bytes': samples[0]['scalar_bytes'], 'samples': samples, + 'median_initialized_delta_kib': statistics.median( + sample['rss_initialized_delta_kib'] for sample in samples), + 'median_saved_delta_kib': statistics.median( + sample['rss_saved_delta_kib'] for sample in samples)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cc', default=os.environ.get('CC', 'cc')) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + with tempfile.TemporaryDirectory(prefix='pure-app-memory-') as root: + directory = Path(root) + shutil.copy2(NATIVE / 'app-memory.c', directory / 'app-memory.c') + generated = directory / 'application.c' + generated_profile = directory / 'fixed-profile.zig' + subprocess.run(['node', str(NATIVE / 'prepare.mjs'), + str(WORKBENCH / 'examples/routed-layout.pure'), + str(generated), str(generated_profile)], check=True) + assert generated.read_bytes() == PREPARED.read_bytes(), 'prepared app is stale' + assert generated_profile.read_bytes() == PROFILE.read_bytes(), 'scalar profile is stale' + baseline = build(args.cc, directory, 'baseline', BASELINE / 'app.c', BASELINE, True) + elastic = build(args.cc, directory, 'elastic', NATIVE / 'app.c', NATIVE, False) + elastic_metrics = build(args.cc, directory, 'elastic-metrics', NATIVE / 'app.c', NATIVE, False, True) + assert baseline['scalar_bytes'] == elastic['scalar_bytes'] == elastic_metrics['scalar_bytes'] + assert elastic['app_controller_bytes'] < baseline['app_controller_bytes'] + assert elastic_metrics['app_controller_bytes'] - elastic['app_controller_bytes'] == 72 + result = {'profile': 'whole-native-app-idle-work-storage', + 'prepared_sha256': digest(PREPARED), 'scalar_archive_sha256': digest(LIBRARY), + 'harness_sha256': digest(NATIVE / 'app-memory.c'), + 'checker_sha256': digest(Path(__file__)), + 'baseline': baseline, 'elastic': elastic, 'elastic_metrics': elastic_metrics, + 'controller_saved_per_app_bytes': baseline['app_controller_bytes'] - elastic['app_controller_bytes'], + 'metrics_storage_saved_per_app_bytes': elastic_metrics['app_controller_bytes'] - elastic['app_controller_bytes'], + 'metrics_initialized_rss_median_saved_kib': elastic_metrics['median_initialized_delta_kib'] - elastic['median_initialized_delta_kib'], + 'metrics_saved_rss_median_saved_kib': elastic_metrics['median_saved_delta_kib'] - elastic['median_saved_delta_kib'], + 'initialized_rss_median_saved_kib': baseline['median_initialized_delta_kib'] - elastic['median_initialized_delta_kib'], + 'saved_rss_median_saved_kib': baseline['median_saved_delta_kib'] - elastic['median_saved_delta_kib']} + output = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.open('x').write(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/workbench/native/desktop.c b/prototypes/workbench/native/desktop.c new file mode 100644 index 0000000..064b181 --- /dev/null +++ b/prototypes/workbench/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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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/native/instance-memory.c b/prototypes/workbench/native/instance-memory.c new file mode 100644 index 0000000..dc771b2 --- /dev/null +++ b/prototypes/workbench/native/instance-memory.c @@ -0,0 +1,49 @@ +#define _POSIX_C_SOURCE 200809L +/* Linux-only evidence harness: 1000 real initialized scalar instances of the + * same prepared app. It measures their process RSS delta, not a full service. */ +#include "plan.h" +#include +#include +#include +#include + +extern const PureScalarPlan pure_app_plan; + +static long rss_kib(void) { + FILE *file = fopen("/proc/self/status", "r"); + if (!file) return -1; + char line[256]; + long result = -1; + while (fgets(line, sizeof(line), file)) { + if (strncmp(line, "VmRSS:", 6) == 0) { + if (sscanf(line + 6, "%ld", &result) != 1) result = -1; + break; + } + } + fclose(file); + return result; +} + +int main(void) { + enum { COUNT = 1000 }; + PureScalar **instances = calloc(COUNT, sizeof(*instances)); + if (!instances) return 2; + long before = rss_kib(); + size_t bytes = pure_scalar_bytes(); + uint64_t checksum = 0; + for (size_t i = 0; i < COUNT; ++i) { + void *memory = malloc(bytes); + if (!memory) return 2; + instances[i] = pure_scalar_initialize(memory, bytes); + if (!instances[i] || pure_scalar_load(instances[i], &pure_app_plan, NULL, NULL)) return 2; + checksum += pure_scalar_info(instances[i], 4); + } + long after = rss_kib(); + if (before < 0 || after < before || checksum != COUNT) return 2; + printf("{\"instances\":%u,\"instance_bytes\":%zu,\"rss_before_kib\":%ld," + "\"rss_after_kib\":%ld,\"rss_delta_kib\":%ld,\"checksum\":%" PRIu64 "}\n", + COUNT, bytes, before, after, after - before, checksum); + for (size_t i = 0; i < COUNT; ++i) free(instances[i]); + free(instances); + return 0; +} diff --git a/prototypes/workbench/native/main.c b/prototypes/workbench/native/main.c new file mode 100644 index 0000000..3756297 --- /dev/null +++ b/prototypes/workbench/native/main.c @@ -0,0 +1,135 @@ +#include "app.h" +#include +#include +#include +#include +#include +#include + +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/native/peer-check.py b/prototypes/workbench/native/peer-check.py new file mode 100644 index 0000000..6a6b49c --- /dev/null +++ b/prototypes/workbench/native/peer-check.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Loopback integration of the optional TLS peer host and an ordinary Python client.""" +import argparse +import json +from pathlib import Path +import select +import socket +import ssl +import struct +import subprocess +import tempfile + + +def run(*args): + subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def certificates(root): + ca = root / 'ca.pem' + run('openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-days', '1', '-subj', '/CN=Pure test CA', + '-addext', 'basicConstraints=critical,CA:TRUE', + '-addext', 'keyUsage=critical,keyCertSign,cRLSign', + '-keyout', root / 'ca.key', '-out', ca) + for role, san, eku in [('server', 'localhost', 'serverAuth'), + ('client', 'pure-client', 'clientAuth'), + ('other', 'wrong-client', 'clientAuth')]: + config = root / (role + '.ext') + config.write_text(f'basicConstraints=critical,CA:FALSE\n' + f'keyUsage=critical,digitalSignature\n' + f'subjectAltName=DNS:{san}\nextendedKeyUsage={eku}\n') + run('openssl', 'req', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-subj', '/CN=' + san, '-keyout', root / (role + '.key'), + '-out', root / (role + '.csr')) + run('openssl', 'x509', '-req', '-in', root / (role + '.csr'), '-CA', ca, + '-CAkey', root / 'ca.key', '-CAcreateserial', '-days', '1', + '-extfile', config, '-out', root / (role + '.pem')) + return ca + + +def frame(payload): + return struct.pack('!H', len(payload)) + payload + + +def recv_exact(sock, size): + result = bytearray() + while len(result) < size: + chunk = sock.recv(size - len(result)) + if not chunk: + raise EOFError('unexpected end of TLS stream') + result += chunk + return bytes(result) + + +def receive(sock): + size, = struct.unpack('!H', recv_exact(sock, 2)) + return recv_exact(sock, size) + + +def peer(root, ca, port, *, cert='client', alpn='pure/1', server_name='localhost'): + ctx = ssl.create_default_context(cafile=str(ca)) + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + ctx.maximum_version = ssl.TLSVersion.TLSv1_3 + if cert: + ctx.load_cert_chain(str(root / (cert + '.pem')), str(root / (cert + '.key'))) + ctx.set_alpn_protocols([alpn]) + plain = socket.create_connection(('127.0.0.1', port), timeout=3) + try: + secure = ctx.wrap_socket(plain, server_hostname=server_name) + secure.settimeout(3) + return secure + except BaseException: + plain.close() + raise + + +def start(binary, root, ca, store): + command = [str(binary), 'serve', '127.0.0.1', '0', str(store), 'scene', + str(ca), str(root / 'server.pem'), str(root / 'server.key'), 'pure-client'] + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1) + try: + assert select.select([proc.stdout], [], [], 5)[0], 'server did not become ready' + line = proc.stdout.readline() + assert line.startswith('READY '), (line, proc.stderr.read()) + return proc, int(line.split()[1]) + except BaseException: + proc.kill() + proc.wait(timeout=3) + raise + + +def stop(proc): + proc.terminate() + proc.communicate(timeout=5) + assert proc.returncode == 0, proc.returncode + + +def state(sock): + sock.sendall(frame(b'\x00')) + reply = receive(sock) + assert len(reply) >= 26 and reply[0] == 0 + revision, stored = struct.unpack_from('!QQ', reply, 1) + assert len(reply) == 26 + 8 * reply[25] + values = struct.unpack_from('!' + 'd' * reply[25], reply, 26) + return revision, stored, values, struct.unpack_from('!I', reply, 21)[0] + + +def rss_kib(pid): + status = Path(f'/proc/{pid}/status') + if not status.exists(): + return None + for line in status.read_text().splitlines(): + if line.startswith('VmRSS:'): + return int(line.split()[1]) + return None + + +def check(binary): + with tempfile.TemporaryDirectory(prefix='pure-peer-check-') as tmp: + root = Path(tmp) + ca = certificates(root) + server, port = start(binary, root, ca, root / 'store') + idle_rss = rss_kib(server.pid) + connected_rss = None + try: + # The peer is an ordinary Python TLS client: no Pure client library. + with peer(root, ca, port) as sock: + hello = receive(sock) + assert len(hello) == 53 and hello[:4] == b'PUR1' + revision, _, _, held = state(sock) + connected_rss = rss_kib(server.pid) + assert held == 0 + stale = struct.pack('!BQ', 2, revision + 1) + sock.sendall(frame(stale)) + conflict = receive(sock) + assert conflict[0] == 2 and struct.unpack_from('!Q', conflict, 1)[0] == revision + assert state(sock)[0] == revision + + # Routed target zero; ending the session while held must cancel it. + down = struct.pack('!BQBBBdd', 1, revision, 4, 0, 0, 90., 130.) + sock.sendall(frame(down)) + assert receive(sock)[0] == 0 + with peer(root, ca, port) as sock: + receive(sock) + revision, _, values, held = state(sock) + assert held == 0, (revision, values, held) + + # A malformed request and overlong frame close their sessions without mutation. + for bad in [frame(b'\xff'), b'\x01\x00']: + with peer(root, ca, port) as sock: + receive(sock) + sock.sendall(bad) + try: + assert sock.recv(2) == b'' + except (ssl.SSLError, ConnectionResetError): + pass + with peer(root, ca, port) as sock: + receive(sock) + assert state(sock)[0] == revision + + # An authenticated client that sends nothing cannot retain the app forever. + with peer(root, ca, port) as sock: + receive(sock) + sock.settimeout(7) + try: + assert sock.recv(1) == b'' + except ssl.SSLError: + pass + with peer(root, ca, port) as sock: + receive(sock) + assert state(sock)[0] == revision + + # Authentication is mandatory on both sides; ALPN is mandatory too. + for kwargs in [{'cert': None}, {'cert': 'other'}, {'alpn': 'http/1.1'}]: + try: + with peer(root, ca, port, **kwargs) as sock: + receive(sock) + except (ssl.SSLError, EOFError, ConnectionResetError, TimeoutError): + pass + else: + raise AssertionError('untrusted or unsupported peer accepted: ' + str(kwargs)) + try: + peer(root, ca, port, server_name='wrong-server').close() + except ssl.SSLError: + pass + else: + raise AssertionError('wrong server name accepted') + + commands = 'point drag down green_box 90 130\npoint drag move green_box 230 210\npoint drag up green_box 230 210\nsave\nquit\n' + result = subprocess.run([str(binary), 'call', '127.0.0.1', str(port), 'localhost', + str(ca), str(root / 'client.pem'), str(root / 'client.key')], + input=commands, text=True, capture_output=True, timeout=10) + assert result.returncode == 0, (result.stdout, result.stderr) + rows = [json.loads(line) for line in result.stdout.splitlines()] + assert rows[-1]['status'] == 0 and rows[-1]['stored_revision'] == '1' + assert rows[-1]['states']['green.x'] == 220 and rows[-1]['states']['green.y'] == 200 + assert rows[-1]['held'] == 0 + finally: + stop(server) + + server, port = start(binary, root, ca, root / 'store') + try: + with peer(root, ca, port) as sock: + receive(sock) + _, stored, _, held = state(sock) + assert stored == 1 and held == 0 + result = subprocess.run([str(binary), 'call', '127.0.0.1', str(port), 'localhost', + str(ca), str(root / 'client.pem'), str(root / 'client.key')], + input='quit\n', text=True, capture_output=True, timeout=10) + assert result.returncode == 0, (result.stdout, result.stderr) + restored = json.loads(result.stdout.splitlines()[0]) + assert restored['states']['green.x'] == 220 and restored['states']['green.y'] == 200 + finally: + stop(server) + return {'python_client': 'pass', 'revision_conflict': 'pass', + 'disconnect_release': 'pass', 'malformed_frames': 'pass', + 'idle_deadline': 'pass', + 'mutual_authentication': 'pass', 'alpn': 'pass', + 'server_hostname': 'pass', 'persist_restart': 'pass', + 'server_idle_rss_kib': idle_rss, 'server_connected_rss_kib': connected_rss} + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('binary', type=Path) + args = parser.parse_args() + print(json.dumps(check(args.binary.resolve()), sort_keys=True)) diff --git a/prototypes/workbench/native/peer-wire-test.c b/prototypes/workbench/native/peer-wire-test.c new file mode 100644 index 0000000..7b28145 --- /dev/null +++ b/prototypes/workbench/native/peer-wire-test.c @@ -0,0 +1,85 @@ +#include "peer-wire.h" +#include +#include +#include +#include + +/* Codec test double; no VM instance or TLS process is needed to check the + * on-wire representation and malformed-frame rejection. */ +double pure_scalar_get_state(PureScalar *scalar, uint32_t id) { + (void)scalar; + return id == 0 ? 12.5 : -0.0; +} +int main(void) { + PureScalarState states[] = {{"x", 0}, {"y", 0}}; + PureScalarInput inputs[] = { + {"key", "Space", NULL, PURE_KEY}, + {"point", NULL, "shape", PURE_POINT}, + {"routed", NULL, NULL, PURE_ROUTED}, + {"value", NULL, NULL, PURE_VALUE}, + }; + PureScalarTarget targets[] = {{2, 0, 1, "shape"}}; + PureScalarPlan plan = {.state_count = 2, .input_count = 4, .target_count = 1, + .states = states, .inputs = inputs, .targets = targets}; + for (unsigned i = 0; i < 32; ++i) plan.identity[i] = (unsigned char)(i + 1); + PureNativeApp app = {.plan = &plan, .revision = 7, .stored_revision = 3}; + unsigned char hello[PURE_PEER_HELLO_BYTES]; + pure_peer_hello(&app, hello); + int64_t revision = -1, stored = -1; + assert(pure_peer_check_hello(hello, sizeof(hello), &plan, &revision, &stored)); + assert(revision == 7 && stored == 3 && !memcmp(hello, "PUR1", 4)); + hello[4] ^= 1; + assert(!pure_peer_check_hello(hello, sizeof(hello), &plan, &revision, &stored)); + hello[4] ^= 1; + assert(!pure_peer_check_hello(hello, sizeof(hello) - 1, &plan, &revision, &stored)); + + unsigned char wire[PURE_PEER_REQUEST_MAX]; + PurePeerRequest request = {.operation = PURE_PEER_STATE}; + assert(pure_peer_encode_request(&request, &plan, wire) == 1); + PurePeerRequest decoded; + assert(pure_peer_decode_request(wire, 1, &plan, &decoded) && decoded.operation == PURE_PEER_STATE); + assert(!pure_peer_decode_request(wire, 2, &plan, &decoded)); + request = (PurePeerRequest){.operation = PURE_PEER_SAVE, .expected = 0x0102030405060708LL}; + assert(pure_peer_encode_request(&request, &plan, wire) == 9); + assert(wire[1] == 1 && wire[8] == 8); + assert(pure_peer_decode_request(wire, 9, &plan, &decoded) && decoded.expected == request.expected); + wire[1] = 255; + assert(!pure_peer_decode_request(wire, 9, &plan, &decoded)); + + request = (PurePeerRequest){.operation = PURE_PEER_ACTION, .expected = 7, + .action = {.input = 0, .phase = PURE_DOWN, .target = UINT32_MAX}}; + assert(pure_peer_encode_request(&request, &plan, wire) == 12); + assert(pure_peer_decode_request(wire, 12, &plan, &decoded) && decoded.action.input == 0); + request.action = (PureAction){.input = 3, .phase = PURE_CHANGE, .target = UINT32_MAX, .x = 12.5}; + assert(pure_peer_encode_request(&request, &plan, wire) == 20); + assert(pure_peer_decode_request(wire, 20, &plan, &decoded) && decoded.action.x == 12.5); + memset(wire + 12, 255, 8); + assert(!pure_peer_decode_request(wire, 20, &plan, &decoded)); + request.action = (PureAction){.input = 1, .phase = PURE_MOVE, .target = UINT32_MAX, .x = 100, .y = -0.0}; + assert(pure_peer_encode_request(&request, &plan, wire) == 28); + assert(pure_peer_decode_request(wire, 28, &plan, &decoded) && decoded.action.x == 100 && signbit(decoded.action.y)); + request.action = (PureAction){.input = 2, .phase = PURE_UP, .target = 0, .x = 5, .y = 6}; + assert(pure_peer_encode_request(&request, &plan, wire) == 28); + assert(pure_peer_decode_request(wire, 28, &plan, &decoded) && decoded.action.target == 0); + wire[11] = 1; + assert(!pure_peer_decode_request(wire, 28, &plan, &decoded)); + request.action = (PureAction){.input = UINT32_MAX, .phase = PURE_FRAME, .target = UINT32_MAX}; + assert(pure_peer_encode_request(&request, &plan, wire) == 12); + assert(pure_peer_decode_request(wire, 12, &plan, &decoded) && decoded.action.phase == PURE_FRAME); + + unsigned char reply[PURE_PEER_REPLY_MAX]; + size_t length = pure_peer_encode_reply(&app, PURE_CONFLICT, reply); + assert(length == 42 && reply[0] == 2 && reply[25] == 2); + PurePeerReply decoded_reply; + assert(pure_peer_decode_reply(reply, length, &plan, &decoded_reply)); + assert(decoded_reply.revision == 7 && decoded_reply.stored_revision == 3); + assert(decoded_reply.states[0] == 12.5 && signbit(decoded_reply.states[1])); + reply[25] = 3; + assert(!pure_peer_decode_reply(reply, length, &plan, &decoded_reply)); + reply[25] = 2; + assert(!pure_peer_decode_reply(reply, length - 1, &plan, &decoded_reply)); + reply[0] = 255; + assert(!pure_peer_decode_reply(reply, length, &plan, &decoded_reply)); + puts("peer wire: bounded identity, revisions, actions, finite states and malformed frames pass"); + return 0; +} diff --git a/prototypes/workbench/native/peer-wire.c b/prototypes/workbench/native/peer-wire.c new file mode 100644 index 0000000..131e709 --- /dev/null +++ b/prototypes/workbench/native/peer-wire.c @@ -0,0 +1,152 @@ +#include "peer-wire.h" +#include +#include +#include +#include + +_Static_assert(sizeof(double) == 8 && FLT_RADIX == 2 && DBL_MANT_DIG == 53 && DBL_MAX_EXP == 1024, + "peer v1 requires IEEE binary64"); + +static void put(unsigned char *at, uint64_t value, unsigned bytes) { + for (unsigned i = 0; i < bytes; ++i) at[bytes - 1 - i] = (unsigned char)(value >> (8 * i)); +} +static uint64_t get(const unsigned char *at, unsigned bytes) { + uint64_t value = 0; + for (unsigned i = 0; i < bytes; ++i) value = (value << 8) | at[i]; + return value; +} +static void put_double(unsigned char *at, double value) { + uint64_t bits; + memcpy(&bits, &value, 8); + put(at, bits, 8); +} +static double get_double(const unsigned char *at) { + uint64_t bits = get(at, 8); + double value; + memcpy(&value, &bits, 8); + return value; +} + +static bool valid_action(const PureScalarPlan *plan, const PureAction *a, size_t bytes) { + if (a->phase == PURE_FRAME || a->phase == PURE_RESET) + return bytes == 12 && a->input == UINT32_MAX && a->target == UINT32_MAX; + if (a->input >= plan->input_count || a->input >= 8) return false; + PureScalarInputKind kind = plan->inputs[a->input].kind; + if (kind == PURE_KEY) + return bytes == 12 && (a->phase == PURE_DOWN || a->phase == PURE_UP) && a->target == UINT32_MAX; + if (kind == PURE_VALUE) + return bytes == 20 && a->phase == PURE_CHANGE && a->target == UINT32_MAX && isfinite(a->x); + if (kind != PURE_POINT && kind != PURE_ROUTED) return false; + if (bytes != 28 || a->phase > PURE_CANCEL || !isfinite(a->x) || !isfinite(a->y)) return false; + if (kind == PURE_POINT) return a->target == UINT32_MAX; + return a->target < plan->target_count && a->target < 24 && + plan->targets[a->target].input == a->input; +} + +void pure_peer_hello(const PureNativeApp *app, unsigned char out[PURE_PEER_HELLO_BYTES]) { + memcpy(out, "PUR1", 4); + memcpy(out + 4, app->plan->identity, 32); + out[36] = (unsigned char)app->plan->state_count; + put(out + 37, (uint64_t)app->revision, 8); + put(out + 45, (uint64_t)app->stored_revision, 8); +} +bool pure_peer_check_hello(const unsigned char *wire, size_t length, const PureScalarPlan *plan, + int64_t *revision, int64_t *stored_revision) { + if (!wire || !plan || !revision || !stored_revision || length != PURE_PEER_HELLO_BYTES || + memcmp(wire, "PUR1", 4) || memcmp(wire + 4, plan->identity, 32) || + wire[36] != plan->state_count) return false; + uint64_t local = get(wire + 37, 8), stored = get(wire + 45, 8); + if (local > INT64_MAX || stored > INT64_MAX) return false; + *revision = (int64_t)local; *stored_revision = (int64_t)stored; + return true; +} + +size_t pure_peer_encode_request(const PurePeerRequest *request, const PureScalarPlan *plan, + unsigned char out[PURE_PEER_REQUEST_MAX]) { + if (!request || !plan || !out || request->operation > PURE_PEER_LOAD) return 0; + out[0] = (unsigned char)request->operation; + if (request->operation == PURE_PEER_STATE) return 1; + if (request->expected < 0) return 0; + put(out + 1, (uint64_t)request->expected, 8); + if (request->operation != PURE_PEER_ACTION) return 9; + const PureAction *a = &request->action; + size_t length = 12; + if (a->phase != PURE_FRAME && a->phase != PURE_RESET && a->input < plan->input_count) { + PureScalarInputKind kind = plan->inputs[a->input].kind; + if (kind == PURE_VALUE) length = 20; + if (kind == PURE_POINT || kind == PURE_ROUTED) length = 28; + } + if (!valid_action(plan, a, length)) return 0; + out[9] = a->input == UINT32_MAX ? 255 : (unsigned char)a->input; + out[10] = (unsigned char)a->phase; + out[11] = a->target == UINT32_MAX ? 255 : (unsigned char)a->target; + if (length >= 20) put_double(out + 12, a->x); + if (length == 28) put_double(out + 20, a->y); + return length; +} + +bool pure_peer_decode_request(const unsigned char *wire, size_t length, const PureScalarPlan *plan, + PurePeerRequest *out) { + if (!wire || !plan || !out || !length || wire[0] > PURE_PEER_LOAD) return false; + PurePeerRequest candidate = {.operation = (PurePeerOperation)wire[0]}; + if (candidate.operation == PURE_PEER_STATE) { + if (length != 1) return false; + } else { + if (length < 9 || get(wire + 1, 8) > INT64_MAX) return false; + candidate.expected = (int64_t)get(wire + 1, 8); + if (candidate.operation != PURE_PEER_ACTION) { + if (length != 9) return false; + } else { + if (length != 12 && length != 20 && length != 28) return false; + candidate.action.input = wire[9] == 255 ? UINT32_MAX : wire[9]; + candidate.action.phase = wire[10]; + candidate.action.target = wire[11] == 255 ? UINT32_MAX : wire[11]; + if (length >= 20) candidate.action.x = get_double(wire + 12); + if (length == 28) candidate.action.y = get_double(wire + 20); + if (!valid_action(plan, &candidate.action, length)) return false; + } + } + *out = candidate; + return true; +} + +/* These numbers belong to the wire version; do not serialize the C enum. */ +static unsigned char wire_status(PureResult code) { + switch (code) { + case PURE_OK: return 0; case PURE_NOT_FOUND: return 1; case PURE_CONFLICT: return 2; + case PURE_BUSY: return 3; case PURE_BUDGET: return 4; case PURE_LIMIT: return 5; + case PURE_STORE_ERROR: return 6; case PURE_UNCERTAIN: return 7; case PURE_INVALID: return 8; + case PURE_DENIED: return 9; case PURE_CLOSED: return 10; case PURE_CANCELLED: return 11; + case PURE_ALLOCATION: return 12; + } + return 8; +} +size_t pure_peer_encode_reply(const PureNativeApp *app, PureResult status, + unsigned char out[PURE_PEER_REPLY_MAX]) { + if (!app || !out || app->plan->state_count > 32 || app->revision < 0 || app->stored_revision < 0) return 0; + out[0] = wire_status(status); + put(out + 1, (uint64_t)app->revision, 8); + put(out + 9, (uint64_t)app->stored_revision, 8); + put(out + 17, app->last_scalar_error, 4); + put(out + 21, app->held, 4); + out[25] = (unsigned char)app->plan->state_count; + for (uint32_t i = 0; i < app->plan->state_count; ++i) + put_double(out + 26 + i * 8, pure_scalar_get_state(app->scalar, i)); + return 26 + 8u * app->plan->state_count; +} +bool pure_peer_decode_reply(const unsigned char *wire, size_t length, const PureScalarPlan *plan, + PurePeerReply *out) { + if (!wire || !plan || !out || length < 26 || plan->state_count > 32 || wire[0] > 12 || + wire[25] != plan->state_count || length != 26 + 8u * plan->state_count) return false; + uint64_t revision = get(wire + 1, 8), stored = get(wire + 9, 8); + if (revision > INT64_MAX || stored > INT64_MAX) return false; + PurePeerReply candidate = {.status = wire[0], .revision = (int64_t)revision, + .stored_revision = (int64_t)stored, .scalar_error = (uint32_t)get(wire + 17, 4), + .held = (uint32_t)get(wire + 21, 4), .state_count = plan->state_count}; + for (uint32_t i = 0; i < plan->state_count; ++i) { + candidate.states[i] = get_double(wire + 26 + i * 8); + if (!isfinite(candidate.states[i])) return false; + } + *out = candidate; + return true; +} diff --git a/prototypes/workbench/native/peer-wire.h b/prototypes/workbench/native/peer-wire.h new file mode 100644 index 0000000..6d78e07 --- /dev/null +++ b/prototypes/workbench/native/peer-wire.h @@ -0,0 +1,32 @@ +#ifndef PURE_PEER_WIRE_H +#define PURE_PEER_WIRE_H +#include "app.h" + +/* Version 1 is a boundary format, not a native struct or a runtime core ABI. + * Every TLS record is still a byte stream: peer.c prefixes these payloads + * with a two-byte big-endian length. One request is outstanding per session. */ +enum { PURE_PEER_HELLO_BYTES = 53, PURE_PEER_REQUEST_MAX = 28, + PURE_PEER_REPLY_MAX = 26 + 32 * 8 }; +typedef enum { PURE_PEER_STATE, PURE_PEER_ACTION, PURE_PEER_SAVE, PURE_PEER_LOAD } PurePeerOperation; +typedef struct { + PurePeerOperation operation; + int64_t expected; + PureAction action; +} PurePeerRequest; +typedef struct { + uint8_t status; + int64_t revision, stored_revision; + uint32_t scalar_error, held, state_count; + double states[32]; +} PurePeerReply; + +void pure_peer_hello(const PureNativeApp *, unsigned char out[PURE_PEER_HELLO_BYTES]); +bool pure_peer_check_hello(const unsigned char *, size_t, const PureScalarPlan *, + int64_t *revision, int64_t *stored_revision); +size_t pure_peer_encode_request(const PurePeerRequest *, const PureScalarPlan *, + unsigned char out[PURE_PEER_REQUEST_MAX]); +bool pure_peer_decode_request(const unsigned char *, size_t, const PureScalarPlan *, PurePeerRequest *); +size_t pure_peer_encode_reply(const PureNativeApp *, PureResult, + unsigned char out[PURE_PEER_REPLY_MAX]); +bool pure_peer_decode_reply(const unsigned char *, size_t, const PureScalarPlan *, PurePeerReply *); +#endif diff --git a/prototypes/workbench/native/peer.c b/prototypes/workbench/native/peer.c new file mode 100644 index 0000000..7a18b3a --- /dev/null +++ b/prototypes/workbench/native/peer.c @@ -0,0 +1,297 @@ +#define _POSIX_C_SOURCE 200809L +/* Optional authenticated Pure-peer adapter. No TLS, socket, or protocol type + * enters runtime.c, the scalar module, or the standalone native CLI. */ +#include "peer-wire.h" +#include "../../common/tls-link.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern const PureScalarPlan pure_app_plan; +static const unsigned char alpn[] = {6, 'p','u','r','e','/','1'}; +static volatile sig_atomic_t stopping; +static void signal_stop(int signum) { (void)signum; stopping = 1; } +static SSL_CTX *context(bool server, const char *ca, const char *cert, const char *key) { + return pure_link_context(server, ca, cert, key, alpn); +} +static bool handshake(PureLink *channel, SSL_CTX *ctx, bool server, const char *peer_name) { + return pure_link_handshake(channel, ctx, server, peer_name, alpn); +} + +/* A connection has one physical input source. Disconnect ends all held input + * phases before another peer can use the same source slot. */ +static void release_inputs(PureNativeApp *app) { + for (uint32_t i = 0; i < app->plan->input_count && i < 8; ++i) { + PureScalarInputKind kind = app->plan->inputs[i].kind; + if (kind == PURE_KEY && (app->key_sources[i] & (1u << 1))) + (void)pure_native_app_action(app, (PureAction){.input = i, .phase = PURE_UP, .source = 1}); + if (kind == PURE_POINT && (app->held & (1u << i)) && app->pointer_owners[i] == 1) + (void)pure_native_app_action(app, (PureAction){.input = i, .phase = PURE_CANCEL, + .target = UINT32_MAX, .source = 1}); + } + for (uint32_t i = 0; i < app->plan->target_count && i < 24; ++i) + if ((app->held & (1u << (8 + i))) && app->pointer_owners[8 + i] == 1) + (void)pure_native_app_action(app, (PureAction){.input = app->plan->targets[i].input, + .phase = PURE_CANCEL, .target = i, .source = 1}); +} +static PureResult execute(PureNativeApp *app, const PurePeerRequest *request) { + if (request->operation == PURE_PEER_STATE) return PURE_OK; + if (request->expected != app->revision) return PURE_CONFLICT; + PureOutcome result; + if (request->operation == PURE_PEER_SAVE) result = pure_native_app_save(app); + else if (request->operation == PURE_PEER_LOAD) result = pure_native_app_restore(app); + else if (request->operation == PURE_PEER_ACTION) { + PureAction action = request->action; + action.source = 1; + result = pure_native_app_action(app, action); + } else return PURE_INVALID; + return result.status; +} +static bool serve_connection(PureLink *channel, PureNativeApp *app) { + unsigned char hello[PURE_PEER_HELLO_BYTES]; + pure_peer_hello(app, hello); + if (!pure_link_write_frame(channel, hello, sizeof(hello), pure_link_now_ms() + PURE_LINK_DEADLINE_MS)) return false; + for (;;) { + unsigned char buffer[PURE_PEER_REQUEST_MAX]; + size_t length = 0; + uint64_t deadline = pure_link_now_ms() + PURE_LINK_DEADLINE_MS; + int got = pure_link_read_frame(channel, buffer, sizeof(buffer), &length, deadline); + if (!got) return true; + if (got < 0) return false; + PurePeerRequest request; + if (!pure_peer_decode_request(buffer, length, app->plan, &request)) return false; + PureResult status = execute(app, &request); + unsigned char reply[PURE_PEER_REPLY_MAX]; + length = pure_peer_encode_reply(app, status, reply); + if (!length || !pure_link_write_frame(channel, reply, length, deadline)) return false; + } +} +static int serve(const char *bind_address, const char *port, const char *directory, + const char *document, const char *ca, const char *cert, const char *key, + const char *client_name, unsigned max_connections) { + if (!pure_link_port_number(port, true) || !client_name || !*client_name) return 2; + SSL_CTX *ctx = context(true, ca, cert, key); + if (!ctx) { ERR_print_errors_fp(stderr); return 2; } + size_t size = pure_scalar_bytes(); + void *memory = malloc(size); + PureNativeApp app; + if (!memory || pure_native_app_init(&app, &pure_app_plan, memory, size, directory, document, 4096) != PURE_OK) { + fprintf(stderr, "app initialization failed\n"); free(memory); SSL_CTX_free(ctx); return 2; + } + PureOutcome restore = pure_native_app_restore(&app); + if (restore.status != PURE_OK && restore.status != PURE_NOT_FOUND) { + fprintf(stderr, "existing state is invalid or unavailable: %u\n", restore.status); + free(memory); SSL_CTX_free(ctx); return 2; + } + int listener = pure_link_listen_at(bind_address, port); + if (listener < 0) { perror("listen"); free(memory); SSL_CTX_free(ctx); return 2; } + struct sockaddr_storage address; + socklen_t length = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &length)) abort(); + unsigned actual_port = address.ss_family == AF_INET + ? ntohs(((struct sockaddr_in *)&address)->sin_port) + : ntohs(((struct sockaddr_in6 *)&address)->sin6_port); + printf("READY %u\n", actual_port); fflush(stdout); + signal(SIGTERM, signal_stop); signal(SIGINT, signal_stop); + unsigned accepted = 0; + while (!stopping && (!max_connections || accepted < max_connections)) { + struct pollfd p = {.fd = listener, .events = POLLIN}; + int result = poll(&p, 1, 1000); + if (result < 0) { if (errno == EINTR) continue; break; } + if (!result) continue; + int fd = accept(listener, NULL, NULL); + if (fd < 0) { if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue; break; } + ++accepted; + PureLink channel = {.fd = fd}; + bool healthy = pure_link_nonblocking(fd) && handshake(&channel, ctx, true, client_name); + if (healthy) healthy = serve_connection(&channel, &app); + pure_link_close(&channel, healthy); + release_inputs(&app); + } + close(listener); + pure_runtime_stop(&app.execution); + int status = app.execution.active || app.execution.retained ? 1 : 0; + free(memory); SSL_CTX_free(ctx); + return status; +} + +static void json_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 print_reply(const PurePeerReply *reply) { + printf("{\"status\":%u,\"revision\":\"%" PRId64 "\",\"stored_revision\":\"%" PRId64 + "\",\"scalar_error\":%u,\"held\":%u,\"states\":{", + reply->status, reply->revision, reply->stored_revision, reply->scalar_error, reply->held); + for (uint32_t i = 0; i < reply->state_count; ++i) { + if (i) putchar(','); + json_string(pure_app_plan.states[i].name); + printf(":%.17g", reply->states[i]); + } + puts("}}"); fflush(stdout); +} +static bool number(const char *text, double *value) { + if (!text || !*text) return false; + char *end; + errno = 0; + *value = strtod(text, &end); + return !errno && !*end && isfinite(*value); +} +static bool parse_command(char *line, PurePeerRequest *request, bool *quit) { + char *words[8]; + size_t count = 0; + for (char *part = strtok(line, " \t\r\n"); part; part = strtok(NULL, " \t\r\n")) { + if (count == 8) return false; + words[count++] = part; + } + if (!count) return false; + *request = (PurePeerRequest){0}; + if (count == 1) { + if (!strcmp(words[0], "quit")) { *quit = true; return true; } + if (!strcmp(words[0], "state")) request->operation = PURE_PEER_STATE; + else if (!strcmp(words[0], "save")) request->operation = PURE_PEER_SAVE; + else if (!strcmp(words[0], "load")) request->operation = PURE_PEER_LOAD; + else if (!strcmp(words[0], "frame") || !strcmp(words[0], "reset")) { + request->operation = PURE_PEER_ACTION; + request->action = (PureAction){.input = UINT32_MAX, .target = UINT32_MAX, + .phase = !strcmp(words[0], "frame") ? PURE_FRAME : PURE_RESET}; + } else return false; + return true; + } + if (count < 3) return false; + uint32_t input = pure_app_plan.input_count; + for (uint32_t i = 0; i < pure_app_plan.input_count; ++i) + if (!strcmp(words[1], pure_app_plan.inputs[i].name)) { input = i; break; } + if (input >= pure_app_plan.input_count) return false; + PureScalarInputKind kind = pure_app_plan.inputs[input].kind; + request->operation = PURE_PEER_ACTION; + request->action = (PureAction){.input = input, .target = UINT32_MAX}; + if (count == 3 && !strcmp(words[0], "key") && kind == PURE_KEY) { + if (!strcmp(words[2], "down")) request->action.phase = PURE_DOWN; + else if (!strcmp(words[2], "up")) request->action.phase = PURE_UP; + else return false; + } else if (count == 3 && !strcmp(words[0], "value") && kind == PURE_VALUE) { + request->action.phase = PURE_CHANGE; + if (!number(words[2], &request->action.x)) return false; + } else if (count == 6 && !strcmp(words[0], "point") && (kind == PURE_POINT || kind == PURE_ROUTED)) { + const char *phases[] = {"down", "up", "move", "cancel"}; + bool phase = false; + for (unsigned i = 0; i < 4; ++i) if (!strcmp(words[2], phases[i])) { + request->action.phase = i; phase = true; break; + } + if (!phase || !number(words[4], &request->action.x) || !number(words[5], &request->action.y)) return false; + if (kind == PURE_POINT) { + if (strcmp(words[3], pure_app_plan.inputs[input].target)) return false; + } else { + bool found = false; + for (uint32_t i = 0; i < pure_app_plan.target_count; ++i) + if (pure_app_plan.targets[i].input == input && !strcmp(words[3], pure_app_plan.targets[i].view)) { + request->action.target = i; found = true; break; + } + if (!found) return false; + } + } else return false; + return true; +} +static bool request_reply(PureLink *channel, const PurePeerRequest *request, PurePeerReply *reply) { + unsigned char wire[PURE_PEER_REPLY_MAX]; + size_t length = pure_peer_encode_request(request, &pure_app_plan, wire); + if (!length || !pure_link_write_frame(channel, wire, length, pure_link_now_ms() + PURE_LINK_DEADLINE_MS)) return false; + int got = pure_link_read_frame(channel, wire, sizeof(wire), &length, pure_link_now_ms() + PURE_LINK_DEADLINE_MS); + return got == 1 && pure_peer_decode_reply(wire, length, &pure_app_plan, reply); +} +static int call(const char *host, const char *port, const char *server_name, + const char *ca, const char *cert, const char *key) { + if (!pure_link_port_number(port, false) || !server_name || !*server_name) return 2; + SSL_CTX *ctx = context(false, ca, cert, key); + if (!ctx) { ERR_print_errors_fp(stderr); return 2; } + PureLink channel = {.fd = pure_link_connect_to(host, port)}; + if (channel.fd < 0 || !handshake(&channel, ctx, false, server_name)) { + fprintf(stderr, "peer authentication or connection failed\n"); + pure_link_close(&channel, false); SSL_CTX_free(ctx); return 2; + } + unsigned char hello[PURE_PEER_HELLO_BYTES]; + size_t length = 0; + int64_t revision, stored; + if (pure_link_read_frame(&channel, hello, sizeof(hello), &length, pure_link_now_ms() + PURE_LINK_DEADLINE_MS) != 1 || + !pure_peer_check_hello(hello, length, &pure_app_plan, &revision, &stored)) { + fprintf(stderr, "peer application definition mismatch\n"); + pure_link_close(&channel, false); SSL_CTX_free(ctx); return 2; + } + (void)stored; + PurePeerReply reply; + PurePeerRequest state = {.operation = PURE_PEER_STATE}; + if (!request_reply(&channel, &state, &reply)) { + fprintf(stderr, "initial state failed\n"); + pure_link_close(&channel, false); SSL_CTX_free(ctx); return 2; + } + print_reply(&reply); + revision = reply.revision; + 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) {} + fprintf(stderr, "command exceeds 510 bytes\n"); failed = 1; continue; + } + PurePeerRequest request; + bool quit = false; + if (!parse_command(line, &request, &quit)) { + fprintf(stderr, "invalid command\n"); failed = 1; continue; + } + if (quit) break; + request.expected = revision; + if (!request_reply(&channel, &request, &reply)) { + fprintf(stderr, "peer request failed or timed out; effect may have committed; query before retry\n"); + failed = 1; break; + } + print_reply(&reply); + revision = reply.revision; + if (reply.status) failed = 1; + } + if (ferror(stdin) || ferror(stdout)) failed = 1; + pure_link_close(&channel, true); SSL_CTX_free(ctx); + return failed; +} + +int main(int argc, char **argv) { + signal(SIGPIPE, SIG_IGN); + if (argc == 8 && !strcmp(argv[1], "call")) + return call(argv[2], argv[3], argv[4], argv[5], argv[6], argv[7]); + if ((argc == 10 || argc == 11) && !strcmp(argv[1], "serve")) { + unsigned limit = 0; + if (argc == 11) { + char *end; + errno = 0; + if (!argv[10][0] || argv[10][0] < '0' || argv[10][0] > '9') goto usage; + unsigned long parsed = strtoul(argv[10], &end, 10); + if (errno || *end || !parsed || parsed > UINT_MAX) goto usage; + limit = (unsigned)parsed; + } + return serve(argv[2], argv[3], argv[4], argv[5], argv[6], argv[7], argv[8], argv[9], limit); + } +usage: + fprintf(stderr, "pure-peer serve BIND_IP PORT STORE_DIR DOCUMENT_KEY CA.pem SERVER_CERT.pem SERVER_KEY.pem CLIENT_DNS [MAX_CONNECTIONS]\n" + "pure-peer call HOST PORT SERVER_DNS CA.pem CLIENT_CERT.pem CLIENT_KEY.pem\n" + "Commands: state, save, load, frame, reset, key NAME down|up, value NAME NUMBER,\n" + "point NAME down|move|up|cancel TARGET X Y, quit.\n"); + return 2; +} diff --git a/prototypes/workbench/native/plan.c b/prototypes/workbench/native/plan.c new file mode 100644 index 0000000..4b82da0 --- /dev/null +++ b/prototypes/workbench/native/plan.c @@ -0,0 +1,45 @@ +#include "plan.h" +#include + +uint32_t pure_scalar_load(PureScalar *vm, const PureScalarPlan *plan, + const PureScalarPlan *previous, const double *restored) { +#ifdef PURE_FIXED_DEFINITION + if (!vm || !plan || previous) return 2; + return pure_scalar_install_fixed(vm, plan->identity, plan->state_count, plan->input_count, + plan->instruction_count, plan->handler_count, + plan->target_count, restored); +#else + 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; +#endif +} diff --git a/prototypes/workbench/native/plan.h b/prototypes/workbench/native/plan.h new file mode 100644 index 0000000..2ca886c --- /dev/null +++ b/prototypes/workbench/native/plan.h @@ -0,0 +1,39 @@ +#ifndef PURE_SCALAR_PLAN_H +#define PURE_SCALAR_PLAN_H +#include "scalar.h" +#include + +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. In fixed CLI/peer builds, + * instructions and handlers are NULL: the same validated program is linked + * once into the scalar module. Other plan fields remain available to hosts. + * 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/native/prepare.mjs b/prototypes/workbench/native/prepare.mjs new file mode 100644 index 0000000..897b75b --- /dev/null +++ b/prototypes/workbench/native/prepare.mjs @@ -0,0 +1,67 @@ +#!/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, profile] = process.argv.slice(2); +if (!input || !output || ![4, 5].includes(process.argv.length)) + throw new Error('prepare.mjs SOURCE.pure OUTPUT.c [FIXED_PROFILE.zig]'); +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 += '#ifndef PURE_FIXED_DEFINITION\n'; +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 += '#endif\n'; +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 += '#ifdef PURE_FIXED_DEFINITION\n#define PURE_INSTRUCTIONS 0\n#define PURE_HANDLERS 0\n#else\n#define PURE_INSTRUCTIONS instructions\n#define PURE_HANDLERS handlers\n#endif\n'; +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, PURE_INSTRUCTIONS, PURE_HANDLERS, targets, views,\n {${[...identity].join(', ')}}\n};\n`; +await writeFile(output, text); +if (profile) { + // A fixed CLI/peer has no source-replacement path. These values size the + // existing VM arrays; GTK and Wasm keep the full authoring capacities. + const limits = [ + ['states', p.states.length], ['inputs', p.inputs.length], + ['instructions', p.instructions.length], ['handlers', p.handlers.length], + ['pointer_targets', p.pointerTargets.length], + ]; + // Zig rejects an index operation on a statically empty array even when a + // preceding runtime bound check makes that operation unreachable. + const profileText = [ + ...limits.map(([name, count]) => `pub const ${name} = ${Math.max(1, count)};`), + ...limits.map(([name, count]) => `pub const ${name}_count = ${count};`), + `pub const identity = [_]u8{${[...identity].join(',')}};`, + `pub const initials = [_]f64{${p.states.map(s => number(s.initial)).join(',')}};`, + 'pub const Instruction = struct { op: u32, arg: u32, value: f64 };', + `pub const code = [_]Instruction{${p.instructions.map(i => + `.{.op=${i.op},.arg=${i.arg},.value=${number(i.value)}}`).join(',')}};`, + 'pub const Handler = struct { event: u32, start: u32, length: u32, base: u8, count: u8 };', + `pub const handlers_data = [_]Handler{${p.handlers.map(h => + `.{.event=${h.event},.start=${h.start},.length=${h.length},.base=${h.parameterBase ?? 0},.count=${h.parameterCount ?? 0}}`).join(',')}};`, + 'pub const Target = struct { input: u8, base: u8, count: u8 };', + `pub const targets_data = [_]Target{${p.pointerTargets.map(t => + `.{.input=${t.input},.base=${t.base},.count=${t.count}}`).join(',')}};`, + `pub const routed_inputs = ${p.pointerTargets.reduce((mask, t) => mask | (1 << t.input), 0)};`, + ].join('\n') + '\n'; + await writeFile(profile, profileText); +} diff --git a/prototypes/workbench/native/scalar.h b/prototypes/workbench/native/scalar.h new file mode 100644 index 0000000..15343d1 --- /dev/null +++ b/prototypes/workbench/native/scalar.h @@ -0,0 +1,51 @@ +#ifndef PURE_SCALAR_H +#define PURE_SCALAR_H +#include +#include +#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); +/* Fixed-definition builds only. The caller's identity and counts must match + * the compiled program; all restored values are checked before publication. */ +uint32_t pure_scalar_install_fixed(PureScalar *instance, const uint8_t identity[32], + uint32_t state_count, uint32_t input_count, uint32_t instruction_count, + uint32_t handler_count, uint32_t target_count, const double *restored); +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/native/test-fixed.c b/prototypes/workbench/native/test-fixed.c new file mode 100644 index 0000000..adf1556 --- /dev/null +++ b/prototypes/workbench/native/test-fixed.c @@ -0,0 +1,68 @@ +#include "plan.h" +#include +#include +#include +#include + +extern const PureScalarPlan pure_app_plan; + +int main(void) { + size_t bytes = pure_scalar_bytes(); + /* This authored three-target app is the selected repeated-instance cost. */ + assert(bytes <= 512); + void *first_memory = malloc(bytes), *second_memory = malloc(bytes); + assert(first_memory && second_memory); + PureScalar *first = pure_scalar_initialize(first_memory, bytes); + PureScalar *second = pure_scalar_initialize(second_memory, bytes); + assert(first && second); + assert(pure_scalar_info(first, 5) == 0 && pure_scalar_info(first, 6) == 0); + assert(pure_scalar_layout(first, 4) == 0 && pure_scalar_layout(first, 5) == 0); + assert(isnan(pure_scalar_get_state(first, 0)) && isnan(pure_scalar_eval_at(first, 0, 0))); + assert(pure_scalar_dispatch(first, 1, 0) == 9); + assert(pure_scalar_dispatch(first, 0, 1.0 / 60.0) == 0); + assert(pure_scalar_info(first, 0) == 1 && pure_scalar_info(first, 9) == 1); + pure_scalar_reset_state(first); + assert(pure_scalar_info(first, 0) == 0 && pure_scalar_info(first, 9) == 0); + assert(first && second && pure_scalar_load(first, &pure_app_plan, NULL, NULL) == 0); + assert(pure_scalar_load(second, &pure_app_plan, NULL, NULL) == 0); + assert(pure_scalar_layout(first, 0) == bytes && pure_scalar_layout(first, 1) > bytes); + assert(pure_scalar_get_state(first, 0) == 0 && pure_scalar_get_state(second, 0) == 0); + assert(pure_scalar_evaluation_info(first, 8) == 0); + assert(pure_scalar_configure_evaluation(first, 1, 1) == 1); + assert(pure_scalar_configure_evaluation(first, 0, 0) == 0); + + /* Two state vectors execute the same compiled program independently. */ + assert(pure_scalar_dispatch_target(first, 9, 0, 90, 130) == 0); + assert(pure_scalar_get_state(first, 0) == 1 && pure_scalar_get_state(second, 0) == 0); + uint32_t before = pure_scalar_info(first, 4); + double values[32] = {0}; + for (uint32_t i = 0; i < pure_app_plan.state_count; ++i) + values[i] = pure_scalar_get_state(first, i); + + PureScalarPlan wrong = pure_app_plan; + wrong.identity[0] ^= 1; + assert(pure_scalar_load(first, &wrong, NULL, NULL) == 2); + wrong = pure_app_plan; + wrong.instruction_count++; + assert(pure_scalar_load(first, &wrong, NULL, NULL) == 2); + assert(pure_scalar_load(first, &pure_app_plan, &pure_app_plan, NULL) == 2); + values[0] = NAN; + assert(pure_scalar_load(first, &pure_app_plan, NULL, values) == 5); + assert(pure_scalar_info(first, 4) == before && pure_scalar_get_state(first, 0) == 1); + values[0] = -0.0; + assert(pure_scalar_load(second, &pure_app_plan, NULL, values) == 0); + assert(signbit(pure_scalar_get_state(second, 0)) && pure_scalar_get_state(first, 0) == 1); + assert(pure_scalar_begin_program(first, pure_app_plan.state_count, pure_app_plan.input_count) == 1); + assert(pure_scalar_info(first, 4) == before && pure_scalar_get_state(first, 0) == 1); + pure_scalar_reset_all(first); + assert(pure_scalar_info(first, 5) == 0); + assert(pure_scalar_load(first, &pure_app_plan, NULL, NULL) == 0); + assert(pure_scalar_get_state(first, 0) == 0); + + printf("{\"native_instances\":2,\"scalar_instance_bytes\":%zu," + "\"shared_program_bytes\":%u,\"checks\":" + "\"empty-state-independent-state-identity-capacity-atomic-restore-signed-zero-reset\"}\n", + bytes, pure_scalar_layout(first, 1)); + free(first_memory); free(second_memory); + return 0; +} diff --git a/prototypes/workbench/native/test.c b/prototypes/workbench/native/test.c new file mode 100644 index 0000000..cc4c529 --- /dev/null +++ b/prototypes/workbench/native/test.c @@ -0,0 +1,152 @@ +#define _POSIX_C_SOURCE 200809L +#include "app.h" +#include "../../connections/store.h" +#include +#include +#include +#include +#include +#include + +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.work == NULL && app->execution.capacity == 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/pointer.test.mjs b/prototypes/workbench/pointer.test.mjs new file mode 100644 index 0000000..8bf530d --- /dev/null +++ b/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/presentation.test.mjs b/prototypes/workbench/presentation.test.mjs new file mode 100644 index 0000000..98bef6e --- /dev/null +++ b/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/profile-refresh.js b/prototypes/workbench/profile-refresh.js new file mode 100644 index 0000000..75c086e --- /dev/null +++ b/prototypes/workbench/profile-refresh.js @@ -0,0 +1,66 @@ +// Playwright CLI function: one diagnostic run, no implementation changes. +async (page) => { + const onDialog = dialog => dialog.accept(); + page.on('dialog', onDialog); + const routeApp = async route => { + const response = await route.fetch(); + let body = await response.text(); + const measured = (key, expression) => `measure('${key}', () => ${expression})`; + body = body.replace("const output = createOutput(document);", `const output = createOutput(document); + function measure(name, action) { + const start = performance.now(); + try { return action(); } finally { + const all = globalThis.__phase ??= {}; + const item = all[name] ??= {calls:0, ms:0}; + item.calls++; item.ms += performance.now() - start; + } + }`); + body = body.replace('function refresh() {', "function refresh() { return measure('refresh', refreshBody); }\nfunction refreshBody() {"); + body = body.replace('function draftChanged() {', "function draftChanged() { return measure('draft', draftBody); }\nfunction draftBody() {"); + body = body.replace('runtime.present(output)', measured('present', 'runtime.present(output)')); + body = body.replace('output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks)', + measured('costs', 'output.costs(snapshot, runtime.currentProgram?.metrics, droppedTicks)')); + await route.fulfill({response, body}); + }; + const client = await page.context().newCDPSession(page); + const metrics = async () => Object.fromEntries((await client.send('Performance.getMetrics')).metrics.map(m => [m.name, m.value])); + const source = ['state x = 40', 'input move = KeyM', + 'box moving(x, 100, 30, 30, "#7ed9ae")', 'text label(20, 40, x)', + ...Array.from({length:22}, (_, i) => `box static${i}(${20 + (i % 7) * 70}, ${200 + Math.floor(i / 7) * 30}, 20, 20, "#8da5bb")`), + 'on move.down { x = x + 0.25 }'].join('\n'); + try { + await page.route('**/app.js', routeApp); + await page.reload(); await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready); + await page.locator('#source').fill(source); await page.locator('#apply').click(); + await page.locator('#scene').focus(); + await page.evaluate(() => { + window.__tap = () => { + const scene = document.getElementById('scene'); + 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++) window.__tap(); + }); + await client.send('Performance.enable'); + const before = await metrics(); + const run = await page.evaluate(() => { + window.__phase = {}; + const start = performance.now(); + for (let i = 0; i < 100; i++) window.__tap(); + return {elapsedMs:performance.now()-start, phase:window.__phase}; + }); + const after = await metrics(); + const delta = Object.fromEntries(['LayoutCount','LayoutDuration','RecalcStyleCount','RecalcStyleDuration','ScriptDuration'] + .map(key => [key, after[key]-before[key]])); + const evidence = {utc:new Date().toISOString(), taps:100, ...run, metrics:delta, source, + scope:'Phase timings overlap: draft/present/costs are inside refresh. CDP counters bracket the synchronous batch and may include surrounding browser work.'}; + await page.evaluate(value => {window.__pureRefreshProfile = value;}, evidence); + return evidence; + } catch (error) { + const evidence = {failed:true, message:error.stack}; + await page.evaluate(value => {window.__pureRefreshProfile = value;}, evidence); + return evidence; + } finally { + await page.unroute('**/app.js', routeApp); page.off('dialog', onDialog); await client.detach(); + } +} diff --git a/prototypes/workbench/project.test.mjs b/prototypes/workbench/project.test.mjs new file mode 100644 index 0000000..ad8a48d --- /dev/null +++ b/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/qa-browser.js b/prototypes/workbench/qa-browser.js new file mode 100644 index 0000000..3590fff --- /dev/null +++ b/prototypes/workbench/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-workbench-blank.png', 'output/playwright/pure-workbench-authored.png', 'output/playwright/pure-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-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/qa-document-guards.js b/prototypes/workbench/qa-document-guards.js new file mode 100644 index 0000000..c938ec3 --- /dev/null +++ b/prototypes/workbench/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/qa-document.js b/prototypes/workbench/qa-document.js new file mode 100644 index 0000000..46b9b8c --- /dev/null +++ b/prototypes/workbench/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😀
문자
\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/qa-evaluation.js b/prototypes/workbench/qa-evaluation.js new file mode 100644 index 0000000..248436a --- /dev/null +++ b/prototypes/workbench/qa-evaluation.js @@ -0,0 +1,89 @@ +// 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(api.warm.dedicatedBytes === 48 && api.warm.sharedBytes === 1152 && + api.before.memory.reservedEngineBytes === 19904, + 'browser runs the shared workspace including document restoration and shared-rule 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-workspace-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-workspace-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/qa-layout.js b/prototypes/workbench/qa-layout.js new file mode 100644 index 0000000..95500ce --- /dev/null +++ b/prototypes/workbench/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/qa-pointer.js b/prototypes/workbench/qa-pointer.js new file mode 100644 index 0000000..0b0c591 --- /dev/null +++ b/prototypes/workbench/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/qa-presentation.js b/prototypes/workbench/qa-presentation.js new file mode 100644 index 0000000..90595e3 --- /dev/null +++ b/prototypes/workbench/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/qa-presentation.py b/prototypes/workbench/qa-presentation.py new file mode 100644 index 0000000..b3adb02 --- /dev/null +++ b/prototypes/workbench/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/qa-refresh.js b/prototypes/workbench/qa-refresh.js new file mode 100644 index 0000000..c74ef4c --- /dev/null +++ b/prototypes/workbench/qa-refresh.js @@ -0,0 +1,141 @@ +// Run through qa-refresh.py. Only the archived app module is replaced for the +// baseline; runtime, compiler, output adapter and Wasm stay identical. +async (page) => { + const baselineApp = /* BASELINE_APP */ null; + const checks = [], samples = [], errors = []; + const check = (ok, name) => { if (!ok) throw new Error(name); checks.push(name); }; + const onDialog = dialog => dialog.accept(); + const onError = error => errors.push(error.message); + page.on('dialog', onDialog); page.on('pageerror', onError); + let variant = 'after'; + const routeApp = async route => { + const response = await route.fetch(); + await route.fulfill({response, body:variant === 'before' ? baselineApp : await response.text()}); + }; + const client = await page.context().newCDPSession(page); + const metrics = async () => Object.fromEntries((await client.send('Performance.getMetrics')).metrics.map(m => [m.name, m.value])); + const source = ['state x = 40', 'input move = KeyM', + 'box moving(x, 100, 30, 30, "#7ed9ae")', 'text label(20, 40, x)', + ...Array.from({length:22}, (_, i) => `box static${i}(${20 + (i % 7) * 70}, ${200 + Math.floor(i / 7) * 30}, 20, 20, "#8da5bb")`), + 'on move.down { x = x + 0.25 }'].join('\n'); + const apply = async value => { await page.locator('#source').fill(value); await page.locator('#apply').click(); }; + const snapshot = () => page.evaluate(() => window.pureWorkbench.snapshot()); + const reload = async () => { + await page.reload(); await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready); + await apply(source); await page.locator('#scene').focus(); + }; + try { + if (!baselineApp) throw new Error('Archived app source is required.'); + await page.route('**/app.js', routeApp); + for (const mode of ['before', 'after', 'after', 'before', 'before', 'after']) { + variant = mode; await reload(); + await page.evaluate(() => { + window.__tap = () => { + const scene = document.getElementById('scene'); + 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++) window.__tap(); + }); + await client.send('Performance.enable'); + const before = await metrics(); + const sample = await page.evaluate(() => { + const observer = new MutationObserver(() => {}); + for (const id of ['line-numbers','source-size','cursor-position','draft-status']) + observer.observe(document.getElementById(id), {subtree:true, childList:true, characterData:true, attributes:true}); + const originalEncode = TextEncoder.prototype.encode; + let encodingCalls = 0, encodedBytes = 0; + TextEncoder.prototype.encode = function(...args) { + const value = originalEncode.apply(this, args); encodingCalls++; encodedBytes += value.byteLength; return value; + }; + let elapsedMs; + try { + const start = performance.now(); + for (let i = 0; i < 100; i++) window.__tap(); + elapsedMs = performance.now() - start; + } finally { TextEncoder.prototype.encode = originalEncode; } + const editorMutations = observer.takeRecords().length; + observer.disconnect(); + return {elapsedMs, encodingCalls, encodedBytes, editorMutations}; + }); + const after = await metrics(); + const s = await snapshot(); + samples.push({variant:mode, taps:100, events:200, ...sample, + metrics:Object.fromEntries(['LayoutCount','LayoutDuration','RecalcStyleCount','RecalcStyleDuration','ScriptDuration'] + .map(key => [key, after[key]-before[key]])), + final:{states:s.states, views:s.views, successfulEvents:s.successfulEvents, tick:s.tick, lastError:s.lastError}, + renderedX:await page.locator('[data-view-id="moving"]').getAttribute('x')}); + } + check(samples.every(s => JSON.stringify(s.final) === JSON.stringify(samples[0].final) && s.renderedX === '70'), + 'six runs preserve exact authored states, outputs, event counts and rendered position'); + check(samples.filter(s => s.variant === 'before').every(s => s.encodingCalls === 400 && s.editorMutations > 0), + 'archived path reproduces unrelated editor work on every event'); + check(samples.filter(s => s.variant === 'after').every(s => s.encodingCalls === 0 && s.editorMutations === 0), + 'runtime input performs no source encoding or editor metadata mutations'); + + variant = 'after'; await reload(); + const draft = source + '\n# 변경🙂\n'; + await page.locator('#source').fill(draft); + const editor = () => page.evaluate(() => ({ + source:document.getElementById('source').value, + size:document.getElementById('source-size').textContent, + lines:document.getElementById('line-numbers').textContent, + status:document.getElementById('draft-status').textContent, + cursor:document.getElementById('cursor-position').textContent, + expectedBytes:new TextEncoder().encode(document.getElementById('source').value).byteLength, + })); + let e = await editor(); + check(e.status === '미적용 변경' && e.size.startsWith(e.expectedBytes.toLocaleString('en-US')) && + e.lines.split('\n').length === draft.split('\n').length, + 'Unicode source editing updates byte count, line numbers and dirty status'); + await page.locator('#scene').focus(); + const prior = await editor(); + await page.keyboard.press('KeyM'); + await page.locator('#step').click(); + check(JSON.stringify(await editor()) === JSON.stringify(prior) && (await snapshot()).states.x === 40.25 && (await snapshot()).tick === 1, + 'keyboard input and frame stepping preserve unapplied editor metadata'); + await page.locator('#reset').click(); + check(JSON.stringify(await editor()) === JSON.stringify(prior) && (await snapshot()).states.x === 40, + 'reset preserves draft and its metadata while restoring active state'); + await page.locator('#apply').click(); + check((await editor()).status === '적용됨' && !(await snapshot()).dirty, + 'successful apply updates draft status without requiring a source edit'); + await apply(draft + 'state broken ='); + check((await editor()).status === '미적용 변경' && (await snapshot()).appliedSource === draft && + await page.locator('#error-location').isVisible(), + 'failed apply preserves dirty status and active work with a source diagnostic'); + await page.locator('#error-location').click(); + check((await editor()).cursor.startsWith(`${draft.split('\n').length}행`), + 'diagnostic navigation still updates the cursor position'); + await page.locator('#source').fill('state count = 1\n'); + await page.locator('#source').press('End'); + await page.locator('#source').press('Tab'); + e = await editor(); + check(e.source.endsWith(' ') && e.size.startsWith(e.expectedBytes.toLocaleString('en-US')), + 'programmatic Tab insertion updates source metadata'); + await apply(''); + check((await editor()).status === '적용됨' && (await snapshot()).views.length === 0, + 'applying an empty program has a correct applied status'); + await page.locator('[data-snippet="state"]').click(); + e = await editor(); + check(e.status === '미적용 변경' && e.source.includes('state count') && + e.size.startsWith(e.expectedBytes.toLocaleString('en-US')), + 'snippet insertion updates source metadata'); + check(errors.length === 0, 'no uncaught browser errors'); + await page.unroute('**/app.js', routeApp); + await page.reload(); await page.waitForFunction(() => window.pureWorkbench?.snapshot().ready); + const evidence = {utc:new Date().toISOString(), checks, errors, samples, source, + browser:await page.evaluate(() => navigator.userAgent), + 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.'}; + await page.evaluate(value => {window.__pureRefreshEvidence = value;}, evidence); + return evidence; + } catch (error) { + const evidence = {failed:true, message:error.stack || error.message, checks, errors, samples}; + await page.evaluate(value => {window.__pureRefreshEvidence = value;}, evidence); + return evidence; + } finally { + await page.unroute('**/app.js', routeApp); + page.off('dialog', onDialog); page.off('pageerror', onError); await client.detach(); + } +} diff --git a/prototypes/workbench/qa-refresh.py b/prototypes/workbench/qa-refresh.py new file mode 100644 index 0000000..e209503 --- /dev/null +++ b/prototypes/workbench/qa-refresh.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Compare editor-independent runtime refresh against the archived app.""" +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-path') +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-refresh/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 = (archive / 'web/app.js').read_text() +script = (ROOT / 'qa-refresh.js').read_text().replace('/* BASELINE_APP */ 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.__pureRefreshEvidence'], + 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-refresh.js', 'qa-refresh.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', 'encodingCalls', 'editorMutations']} + 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/qa-release.js b/prototypes/workbench/qa-release.js new file mode 100644 index 0000000..1674c5b --- /dev/null +++ b/prototypes/workbench/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/qa-routed-input.js b/prototypes/workbench/qa-routed-input.js new file mode 100644 index 0000000..b935499 --- /dev/null +++ b/prototypes/workbench/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/qa-shared-rules.js b/prototypes/workbench/qa-shared-rules.js new file mode 100644 index 0000000..6dad3dc --- /dev/null +++ b/prototypes/workbench/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/remote.mjs b/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/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-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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-22-evaluation/sources/web/core.wasm 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-22-linux-aarch64/authored.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-22-linux-aarch64/blank.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-22-linux-aarch64/mobile.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-layout/pure-layout-desktop.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-layout/pure-layout-mobile.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-layout/sources/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

직접 조작
input drag = pointer(tile)로 도형에 입력을 연결합니다. on drag.down / .move / .up / .cancel에서 event_x, event_y는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 .cancel이 전달됩니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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 + +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 +#include + +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 + +#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 + +/* 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 +#include + +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 +#include +#include +#include +#include +#include +#include +#include +#include + +/* 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 + +#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 +#include +#include + +_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 +#include +#include +#include +#include +#include + +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 + +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 + +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 +#include +#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 +#include +#include +#include +#include +#include + +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{ + 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{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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-native-instances/sources/prototypes/workbench/web/core.wasm 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer-routing/before/core.wasm 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-desktop.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer-routing/pure-routed-layout-mobile.png 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{ + 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer-routing/sources/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

직접 조작
input drag = pointer(tile)로 도형에 입력을 연결합니다. on drag.down / .move / .up / .cancel에서 event_x, event_y는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 .cancel이 전달됩니다.

값과 규칙 재사용
record Tile { x=0; y=0 }로 필드를 정하고 item green = Tile(x=80)으로 대상을 만듭니다. rule move(t: Tile) { t.x=event_x }를 on drag.move use move(green)으로 연결합니다. 화면에서는 green.x를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.

여러 도형의 입력 묶기
input drag = pointer { green_box: green; blue_box: blue }로 연결하고 on drag.move use move(drag.target)으로 현재 조작 대상을 전달합니다. 같은 레코드 타입의 대상끼리 묶으며, 각 도형은 따로 잡고 놓을 수 있습니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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. (evalmachine.: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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer/after/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

직접 조작
input drag = pointer(tile)로 도형에 입력을 연결합니다. on drag.down / .move / .up / .cancel에서 event_x, event_y는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 .cancel이 전달됩니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer/before/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-desktop.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-pointer/pure-pointer-mobile.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-presentation/before/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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": "x: 70" + }, + { + "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": "x: 70" + }, + { + "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": "x: 70" + }, + { + "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": "x: 70" + }, + { + "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": "x: 70" + }, + { + "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": "x: 70" + } + ], + "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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-refresh/before/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-shared-rules/before/web/core.wasm 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-desktop.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-shared-rules/pure-shared-layout-mobile.png 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{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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-shared-rules/sources/web/core.wasm 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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

직접 조작
input drag = pointer(tile)로 도형에 입력을 연결합니다. on drag.down / .move / .up / .cancel에서 event_x, event_y는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 .cancel이 전달됩니다.

값과 규칙 재사용
record Tile { x=0; y=0 }로 필드를 정하고 item green = Tile(x=80)으로 대상을 만듭니다. rule move(t: Tile) { t.x=event_x }를 on drag.move use move(green)으로 연결합니다. 화면에서는 green.x를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-web-layout/before/core.wasm 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😀
문자
\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😀
문자
\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😀
문자
\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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-desktop.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-web-layout/pure-document-mobile.png 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 파일 +외부 편집기에서 만든 내용 +😀
문자
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😀
문자
\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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-23-web-layout/sources/web/core.wasm 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 @@ + + + + + + Pure · 문서 작업 + + +
pure / 문서 작업텍스트 워크스페이스
+
+

WRITE / READ

쓰고, 읽고, 공간을 조절하세요.

가운데 경계를 움직여 편한 폭을 만드세요. 좁은 화면에서는 위아래로 이어집니다.

+
+ + + + UTF-8 텍스트 파일을 열거나 바로 작성하세요. +
+
+ 50% + 경계에서 ← → · Shift로 크게 · Esc로 취소 +
+
+

작성

TEXT
+ +

읽기

PREVIEW

작성한 내용이 여기에 나타납니다.

+
+
분할 규칙 수정

비율을 받는 규칙을 수정할 수 있습니다. 문서 내용은 유지됩니다. 숫자 입력 이름은 split, 결과 상태는 ratio입니다.

실행기를 준비하고 있습니다.
+
텍스트는 일반 .txt 파일로 저장됩니다. 줄바꿈과 글자 배치는 브라우저가 처리합니다. 기존 작업 공간 ↗
+
+ + + 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로 내려받습니다. 다른 편집기에서 그대로 읽을 수 있는 경로를 유지하는 것이 이 작업의 조건입니다. + +아래 문자열은 코드가 아니라 문서의 일부입니다. +
이 글은 화면의 HTML 요소로 해석되지 않습니다.
+ +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 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

숫자 입력
input level = value와 on level.change { amount = event_value }로 숫자 컨트롤을 연결합니다. 웹 호스트와 CLI에서도 같은 값을 전달할 수 있습니다.

직접 조작
input drag = pointer(tile)로 도형에 입력을 연결합니다. on drag.down / .move / .up / .cancel에서 event_x, event_y는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 .cancel이 전달됩니다.

값과 규칙 재사용
record Tile { x=0; y=0 }로 필드를 정하고 item green = Tile(x=80)으로 대상을 만듭니다. rule move(t: Tile) { t.x=event_x }를 on drag.move use move(green)으로 연결합니다. 화면에서는 green.x를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.

여러 도형의 입력 묶기
input drag = pointer { green_box: green; blue_box: blue }로 연결하고 on drag.move use move(drag.target)으로 현재 조작 대상을 전달합니다. 같은 레코드 타입의 대상끼리 묶으며, 각 도형은 따로 잡고 놓을 수 있습니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

문서 편집 · 반응형 레이아웃 작업 화면 ↗

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + 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//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 +#include +#include +#include +#include + +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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/drag-and-save.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/numeric-input.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/recovered-edit.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/rejected-edit.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/reopened-state.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/resized-input.png 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 Binary files /dev/null and b/prototypes/workbench/results/2026-09-24-native-desktop/resized.png 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 +#include +#include + +_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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 +#include +#include +#include +#include +#include + +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 diff --git a/prototypes/workbench/routing.test.mjs b/prototypes/workbench/routing.test.mjs new file mode 100644 index 0000000..bae04de --- /dev/null +++ b/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{ + 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/rules.test.mjs b/prototypes/workbench/rules.test.mjs new file mode 100644 index 0000000..b4c4969 --- /dev/null +++ b/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{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/runtime.test.mjs b/prototypes/workbench/runtime.test.mjs new file mode 100644 index 0000000..86bc9ba --- /dev/null +++ b/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/value-input.test.mjs b/prototypes/workbench/value-input.test.mjs new file mode 100644 index 0000000..3044048 --- /dev/null +++ b/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/web/app.js b/prototypes/workbench/web/app.js new file mode 100644 index 0000000..9e53ee3 --- /dev/null +++ b/prototypes/workbench/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/web/baseline.html b/prototypes/workbench/web/baseline.html new file mode 100644 index 0000000..cc9f569 --- /dev/null +++ b/prototypes/workbench/web/baseline.html @@ -0,0 +1,108 @@ + + + + +Pure 비교 실험 · 일반 JavaScript + +← 빈 작업물 제작기로 +

일반 JavaScript로 작성한 충전 점프

+

동일한 충전·점프 동작을 직접 작성한 비교용 앱입니다. 실행을 켜고 Space 또는 아래 버튼을 누르다가 놓아보세요. 정지 중에는 한 프레임씩 진행할 수 있습니다.

+ + + + + Charge: 0 + +
+ + + + + 정지 · 0 프레임 +
+

+

이 앱은 고정된 JavaScript 프로그램입니다. 코드를 편집하고 다시 불러와 동작을 바꿀 수 있지만, 제작기의 정의 적용·상태 보존 기능은 구현하지 않았습니다. 아래 파일과 충전 예제를 비교할 수 있습니다.

+

행동과 상태 소스 · baseline.mjs · 동일 동작의 Pure 정의 · charge.pure

+ + diff --git a/prototypes/workbench/web/baseline.mjs b/prototypes/workbench/web/baseline.mjs new file mode 100644 index 0000000..3873e31 --- /dev/null +++ b/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/web/compiler.mjs b/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/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/web/core.wasm b/prototypes/workbench/web/core.wasm new file mode 100755 index 0000000..7aa443c Binary files /dev/null and b/prototypes/workbench/web/core.wasm differ diff --git a/prototypes/workbench/web/document.css b/prototypes/workbench/web/document.css new file mode 100644 index 0000000..7e7ed97 --- /dev/null +++ b/prototypes/workbench/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/web/document.html b/prototypes/workbench/web/document.html new file mode 100644 index 0000000..7e96674 --- /dev/null +++ b/prototypes/workbench/web/document.html @@ -0,0 +1,32 @@ + + + + + + Pure · 문서 작업 + + +
pure / 문서 작업텍스트 워크스페이스
+
+

WRITE / READ

쓰고, 읽고, 공간을 조절하세요.

가운데 경계를 움직여 편한 폭을 만드세요. 좁은 화면에서는 위아래로 이어집니다.

+
+ + + + UTF-8 텍스트 파일을 열거나 바로 작성하세요. +
+
+ 50% + 경계에서 ← → · Shift로 크게 · Esc로 취소 +
+
+

작성

TEXT
+ +

읽기

PREVIEW

작성한 내용이 여기에 나타납니다.

+
+
분할 규칙 수정

비율을 받는 규칙을 수정할 수 있습니다. 문서 내용은 유지됩니다. 숫자 입력 이름은 split, 결과 상태는 ratio입니다.

실행기를 준비하고 있습니다.
+
텍스트는 일반 .txt 파일로 저장됩니다. 줄바꿈과 글자 배치는 브라우저가 처리합니다. 기존 작업 공간 ↗
+
+ + + diff --git a/prototypes/workbench/web/document.mjs b/prototypes/workbench/web/document.mjs new file mode 100644 index 0000000..1f19322 --- /dev/null +++ b/prototypes/workbench/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로 내려받습니다. 다른 편집기에서 그대로 읽을 수 있는 경로를 유지하는 것이 이 작업의 조건입니다. + +아래 문자열은 코드가 아니라 문서의 일부입니다. +
이 글은 화면의 HTML 요소로 해석되지 않습니다.
+ +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/web/index.html b/prototypes/workbench/web/index.html new file mode 100644 index 0000000..089302c --- /dev/null +++ b/prototypes/workbench/web/index.html @@ -0,0 +1,95 @@ + + + + + + + Pure · 빈 작업물에서 시작하기 + + + + +
+ pure / workbench + 제작 경험 실험 004 +
실행기 준비 중
+
+ +
+
+

빈 코드 · 직접 만든 동작

무엇을 움직이게 할까요?

+

상태를 만들고, 입력에 동작을 연결하세요.
코드를 적용하면 장면이 바뀝니다. 같은 이름의 상태는 이어집니다.

+
+ +
+
+

01 코드

빈 작업물
+
+ + + + + + +
+ +
+ + +
+ +

편집만으로 실행 중인 코드는 바뀌지 않습니다.

+
아직 작성한 코드가 없습니다.
+
+ +
+
+

02 실행 장면

적용 전
+
+ 사용자가 코드로 만든 장면. 현재 비어 있습니다. +
아직 아무것도 없습니다도형이나 글자를 선언하고 코드를 적용하세요.
+ +
+
0 frame · 정지
+

정지하면 시간만 멈춥니다. 아래 입력은 정지 중에도 작동합니다.

+

내가 선언한 입력

눌렀을 때 .down / 놓았을 때 .up
+

입력을 선언하면 여기에 버튼이 생깁니다.

+

장면을 클릭하면 키보드로 입력할 수 있습니다. 코드나 폼을 편집하는 동안에는 전달되지 않습니다.

+
+ +
+

03 현재 상태

revision 0
+

선언한 상태의 값이 표시됩니다.

+
마지막 입력아직 없음
+ +
실행 비용과 범위

코어가 보고한 WebAssembly 메모리입니다. 브라우저·편집기·렌더러의 메모리와 프로세스 전체 사용량은 포함하지 않습니다. 예약 공간 안에 사용 중인 코드·상태·스테이징 공간이 포함되므로 합산하지 않습니다.

시간 지연으로 건너뛴 프레임: 0

+
+
+
+ +
+

언어 안내

조각을 조합해 자신의 동작을 만드세요.
+
+

1. 기억할 값

이름은 영문으로, 처음 값은 숫자로 정합니다.

state count = 0
+state x = 80
+

2. 입력과 반응

버튼 이름과 키를 선언하고 .down / .up에 연결합니다.

input tap = Space
+on tap.down {
+  count = count + 1
+}
+

3. 보일 것

좌표와 크기를 상태에 연결할 수 있습니다.

box tile(x, 180, 40, 40, "#7ed9ae")
+text label(40, 60, count, "Count: ")
+

4. 시간에 따른 변화

실행하면 매 프레임 반응합니다. dt는 1/60초입니다.

on frame {
+  x = x + 30 * dt
+  if x > 600 { x = 0 }
+}
+
+
표현식 · 입력 키 · 제한 보기

표현식
+ - * /, < <= > >= == !=, && || !, 괄호, min(a,b), max(a,b), clamp(x,lo,hi)를 사용합니다. 논리 연산은 양쪽을 모두 계산합니다.

도형
box id(x,y,width,height,"#rrggbb")
circle id(x,y,radius,"#rrggbb")
text id(x,y,value,"접두사")
글자의 value는 숫자 표현식입니다. 문자 접두사는 생략할 수 있습니다.

입력
Space, Enter, ArrowLeft/Right/Up/Down, KeyA~KeyZ, Digit0~Digit9. 같은 키는 하나의 입력에 연결합니다.

숫자 입력
input level = value와 on level.change { amount = event_value }로 숫자 컨트롤을 연결합니다. 웹 호스트와 CLI에서도 같은 값을 전달할 수 있습니다.

직접 조작
input drag = pointer(tile)로 도형에 입력을 연결합니다. on drag.down / .move / .up / .cancel에서 event_x, event_y는 장면 좌표입니다. 이동 방식과 취소 시 동작도 코드로 정합니다. 초점이나 포인터 캡처를 잃으면 .cancel이 전달됩니다.

값과 규칙 재사용
record Tile { x=0; y=0 }로 필드를 정하고 item green = Tile(x=80)으로 대상을 만듭니다. rule move(t: Tile) { t.x=event_x }를 on drag.move use move(green)으로 연결합니다. 화면에서는 green.x를 읽습니다. 규칙은 연결한 대상의 값을 직접 바꿉니다.

여러 도형의 입력 묶기
input drag = pointer { green_box: green; blue_box: blue }로 연결하고 on drag.move use move(drag.target)으로 현재 조작 대상을 전달합니다. 같은 레코드 타입의 대상끼리 묶으며, 각 도형은 따로 잡고 놓을 수 있습니다.

이 실험의 범위
상태 32개 · 입력 8개 · 도형/글자 24개 · 반응 64개 · 컴파일된 명령 512개 · 코드 32 KiB. 반복문, 함수 선언, 자동 충돌·물리는 없습니다. 줄바꿈 또는 세미콜론으로 구분하고 #으로 주석을 씁니다.

0으로 나누기 등 계산 실패 시 해당 이벤트의 상태 변경 전체를 취소합니다. 입력을 누르고 있을 때는 새 코드를 적용할 수 없습니다.

+

문서 편집 · 반응형 레이아웃 작업 화면 ↗

+

선택한 코드를 편집기에 불러옵니다. 자동으로 적용되지 않습니다.

+
같은 동작을 일반 JavaScript로 만든 비교 앱 ↗충전 예제와 비교할 수 있습니다. 새 탭에서 열립니다.
+
+
Pure · authoring experiment

원하는 동작을 만들어보고, 하려던 일 / 실제로 일어난 일 / 기대했던 일을 남겨주세요.

+
+ + + diff --git a/prototypes/workbench/web/output.mjs b/prototypes/workbench/web/output.mjs new file mode 100644 index 0000000..694fce6 --- /dev/null +++ b/prototypes/workbench/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/web/project.mjs b/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/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/web/runtime.mjs b/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/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/web/scene-input.mjs b/prototypes/workbench/web/scene-input.mjs new file mode 100644 index 0000000..0daf079 --- /dev/null +++ b/prototypes/workbench/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/web/split-input.mjs b/prototypes/workbench/web/split-input.mjs new file mode 100644 index 0000000..294e9a3 --- /dev/null +++ b/prototypes/workbench/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/web/style.css b/prototypes/workbench/web/style.css new file mode 100644 index 0000000..702a6e4 --- /dev/null +++ b/prototypes/workbench/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/workspace-check.mjs b/prototypes/workbench/workspace-check.mjs new file mode 100644 index 0000000..063ba42 --- /dev/null +++ b/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/workspace.test.mjs b/prototypes/workbench/workspace.test.mjs new file mode 100644 index 0000000..799f056 --- /dev/null +++ b/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); +}); -- cgit v1.2.3