From c5af03654af228c76dcad62a1f5d1912c64d8d64 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 2 Oct 2026 10:50:20 +0200 Subject: [PATCH 01/20] Add a compiler from Petri nets to Zeroth reactive modules, with its playground pocs/zeroth-playground holds three folders: - compiler/ reads a Petri net written in YAML and writes the Python that builds it as Zeroth reactive modules with zrth.sugar. It imports only js-yaml and zod. Options choose the shape, how rates fire, how conflicts resolve, the marking theory, control, the time step, slots and the file layout. Every line of output is traced back to the item of the net it comes from. A code parser can be injected for nets whose guards, rates or kernels are code. - examples/ holds one folder per example: the net, its page, its options and its figures. - playground/ is a Vite and React app that builds to one HTML file: the net, the Python, a preview of the net, a page per example, and a view of the compiler's stages. compiler/mapping.md is the design shared by HASH and Zeroth, with the open questions on the semantics. pnpm check:zrth imports every example's Python into a zrth checkout to check that it constructs. --- README.md | 1 + pocs/zeroth-playground/.gitignore | 6 + pocs/zeroth-playground/AGENTS.md | 44 + pocs/zeroth-playground/CLAUDE.md | 1 + pocs/zeroth-playground/README.md | 32 + pocs/zeroth-playground/compiler/README.md | 382 +++ .../compiler/boundary.test.ts | 92 + .../compiler/code/code-strings.test.ts | 42 + .../compiler/code/code-strings.ts | 37 + .../compiler/code/code-tree.ts | 111 + .../compiler/code/linear-code.test.ts | 192 ++ .../compiler/code/linear-code.ts | 417 +++ .../compiler/compile.test.ts | 159 + pocs/zeroth-playground/compiler/compile.ts | 85 + .../zeroth-playground/compiler/diagnostics.ts | 106 + .../compiler/emit/describe.test.ts | 227 ++ .../compiler/emit/describe.ts | 202 ++ .../compiler/emit/emit.test.ts | 85 + pocs/zeroth-playground/compiler/emit/emit.ts | 25 + .../compiler/emit/linear.test.ts | 157 + .../zeroth-playground/compiler/emit/linear.ts | 185 ++ .../compiler/emit/python-writer.test.ts | 52 + .../compiler/emit/python-writer.ts | 58 + .../compiler/emit/python.test.ts | 89 + .../zeroth-playground/compiler/emit/python.ts | 321 ++ .../compiler/emit/spn.test.ts | 239 ++ pocs/zeroth-playground/compiler/emit/spn.ts | 126 + .../compiler/graph/linear-graph.ts | 194 ++ .../compiler/graph/module-graph.ts | 5 + .../compiler/graph/spn-graph.ts | 180 ++ pocs/zeroth-playground/compiler/index.ts | 61 + .../compiler/ir/accessors.test.ts | 40 + .../compiler/ir/accessors.ts | 34 + .../zeroth-playground/compiler/ir/net-item.ts | 42 + .../compiler/ir/parse.test.ts | 167 + pocs/zeroth-playground/compiler/ir/parse.ts | 73 + .../compiler/ir/references.test.ts | 214 ++ .../compiler/ir/references.ts | 150 + .../compiler/ir/reserved-names.ts | 43 + pocs/zeroth-playground/compiler/ir/schema.ts | 176 + .../compiler/ir/yaml-keys.test.ts | 54 + .../compiler/ir/yaml-keys.ts | 66 + .../compiler/lower/clocks.test.ts | 183 ++ .../compiler/lower/clocks.ts | 204 ++ .../compiler/lower/clocks/refusals.test.ts | 145 + .../compiler/lower/clocks/refusals.ts | 83 + .../compiler/lower/colour-layout.ts | 202 ++ .../compiler/lower/guard-inputs.ts | 145 + .../compiler/lower/lower.test.ts | 217 ++ .../zeroth-playground/compiler/lower/lower.ts | 55 + .../compiler/lower/lowering.ts | 12 + .../compiler/lower/marking-terms.ts | 19 + .../compiler/lower/modular.test.ts | 218 ++ .../compiler/lower/modular.ts | 238 ++ .../compiler/lower/monolithic.test.ts | 332 ++ .../compiler/lower/monolithic.ts | 415 +++ .../compiler/lower/monolithic/bindings.ts | 268 ++ .../compiler/lower/monolithic/colour.test.ts | 260 ++ .../compiler/lower/monolithic/compaction.ts | 142 + .../compiler/lower/monolithic/dynamics.ts | 91 + .../compiler/lower/monolithic/kernels.ts | 165 + .../lower/monolithic/root-bindings.ts | 35 + .../compiler/lower/names.test.ts | 232 ++ .../zeroth-playground/compiler/lower/names.ts | 408 +++ .../compiler/lower/step-plan.ts | 238 ++ pocs/zeroth-playground/compiler/mapping.md | 361 +++ .../compiler/options.test.ts | 304 ++ pocs/zeroth-playground/compiler/options.ts | 241 ++ .../zeroth-playground/compiler/readme.test.ts | 51 + .../compiler/scripts/check-zrth.ts | 239 ++ .../compiler/scripts/construct_net.py | 39 + .../compiler/scripts/option-variants.test.ts | 47 + .../compiler/scripts/option-variants.ts | 64 + .../compiler/scripts/resolve-ts.ts | 17 + .../compiler/testing/birth-death.fixtures.ts | 86 + .../compiler/testing/code-builders.ts | 54 + .../compiler/testing/code-trees.json | 1056 ++++++ .../testing/coloured-nets.fixtures.ts | 140 + .../compiler/testing/compile-net.ts | 38 + .../compiler/testing/fixture-parser.ts | 27 + .../compiler/testing/fork.fixtures.ts | 29 + .../compiler/testing/line-containing.ts | 8 + .../compiler/testing/random-net.ts | 106 + .../compiler/testing/raw-text.d.ts | 5 + .../compiler/testing/reference-step.ts | 69 + .../compiler/testing/run-linear-graph.ts | 318 ++ .../compiler/testing/small-nets.fixtures.ts | 77 + .../compiler/trace/describe-item.ts | 73 + .../compiler/trace/provenance.test.ts | 92 + .../compiler/trace/provenance.ts | 93 + .../compiler/trace/trace-ir.test.ts | 198 ++ .../compiler/trace/trace-ir.ts | 191 ++ pocs/zeroth-playground/compiler/tsconfig.json | 8 + pocs/zeroth-playground/examples/README.md | 114 + .../examples/__snapshots__/birth-death/net.py | 52 + .../examples/__snapshots__/cafe-queue/net.py | 114 + .../examples/__snapshots__/capacity/net.py | 111 + .../examples/__snapshots__/conflict/net.py | 83 + .../examples/__snapshots__/cycle/net.py | 63 + .../__snapshots__/fork-clocked/net.py | 115 + .../examples/__snapshots__/fork/net.py | 98 + .../examples/__snapshots__/queue/net.py | 65 + .../examples/__snapshots__/sir-model/net.py | 86 + .../components/coin-clock-explorer.css | 187 ++ .../components/coin-clock-explorer.tsx | 62 + .../coin-clock-explorer/exponential.test.ts | 140 + .../coin-clock-explorer/exponential.ts | 42 + .../components/coin-clock-explorer/format.ts | 4 + .../modifier-class.test.ts | 12 + .../coin-clock-explorer/modifier-class.ts | 4 + .../coin-clock-explorer/plot-frame.test.ts | 47 + .../coin-clock-explorer/plot-frame.ts | 62 + .../coin-clock-explorer/range-field.tsx | 39 + .../coin-clock-explorer/survival-plot.tsx | 155 + .../coin-clock-explorer/use-width.ts | 23 + .../examples/birth-death/meta.ts | 11 + .../examples/birth-death/net.pn.yaml | 16 + .../examples/birth-death/page.mdx | 52 + .../zeroth-playground/examples/boiler/meta.ts | 11 + .../examples/boiler/net.pn.yaml | 33 + .../examples/boiler/page.mdx | 29 + .../zeroth-playground/examples/bucket/meta.ts | 11 + .../examples/bucket/net.pn.yaml | 26 + .../examples/bucket/page.mdx | 15 + .../examples/cafe-queue/meta.ts | 11 + .../examples/cafe-queue/net.pn.yaml | 33 + .../examples/cafe-queue/page.mdx | 30 + .../examples/capacity/meta.ts | 11 + .../examples/capacity/net.pn.yaml | 30 + .../examples/capacity/page.mdx | 27 + .../examples/catalog.test.ts | 29 + pocs/zeroth-playground/examples/catalog.ts | 78 + .../examples/conflict/meta.ts | 11 + .../examples/conflict/net.pn.yaml | 24 + .../examples/conflict/page.mdx | 36 + .../examples/cycle/components/one-step.css | 105 + .../examples/cycle/components/one-step.tsx | 33 + .../cycle/components/one-step/marking.tsx | 29 + pocs/zeroth-playground/examples/cycle/meta.ts | 11 + .../examples/cycle/net.pn.yaml | 21 + .../zeroth-playground/examples/cycle/page.mdx | 38 + .../zeroth-playground/examples/drones/meta.ts | 11 + .../examples/drones/net.pn.yaml | 53 + .../examples/drones/page.mdx | 19 + .../examples/examples.test.ts | 57 + .../fork-clocked/components/clock-race.css | 209 ++ .../fork-clocked/components/clock-race.tsx | 44 + .../examples/fork-clocked/meta.ts | 11 + .../examples/fork-clocked/net.pn.yaml | 30 + .../examples/fork-clocked/page.mdx | 42 + pocs/zeroth-playground/examples/fork/meta.ts | 11 + .../examples/fork/net.pn.yaml | 27 + pocs/zeroth-playground/examples/fork/page.mdx | 10 + .../examples/queue/components/coin-draws.css | 103 + .../examples/queue/components/coin-draws.tsx | 73 + pocs/zeroth-playground/examples/queue/meta.ts | 11 + .../examples/queue/net.pn.yaml | 19 + .../zeroth-playground/examples/queue/page.mdx | 21 + .../examples/sir-model/meta.ts | 11 + .../examples/sir-model/net.pn.yaml | 28 + .../examples/sir-model/page.mdx | 31 + pocs/zeroth-playground/package.json | 54 + pocs/zeroth-playground/playground/README.md | 127 + pocs/zeroth-playground/playground/app/app.tsx | 70 + .../playground/app/document.test.ts | 65 + .../playground/app/document.ts | 58 + .../playground/app/example-picker.tsx | 141 + .../playground/app/header.tsx | 58 + .../playground/app/route.test.ts | 32 + .../zeroth-playground/playground/app/route.ts | 19 + .../playground/app/view-switch.tsx | 30 + .../compiler-view/compiler-guide.mdx | 59 + .../compiler-view/compiler-view.css | 752 +++++ .../compiler-view/compiler-view.tsx | 102 + .../playground/compiler-view/focus-handoff.ts | 22 + .../compiler-view/pipeline-graph.tsx | 88 + .../pipeline-graph/pipeline-edge.tsx | 47 + .../pipeline-graph/pipeline-node.tsx | 76 + .../compiler-view/pipeline-layout.test.ts | 90 + .../compiler-view/pipeline-layout.ts | 169 + .../playground/compiler-view/rich-text.tsx | 24 + .../playground/compiler-view/stage-card.tsx | 86 + .../compiler-view/stage-card/sample-view.tsx | 47 + .../playground/compiler-view/stage-focus.tsx | 59 + .../compiler-view/stage-overview.tsx | 55 + .../playground/compiler-view/stage-ref.tsx | 30 + .../compiler-view/stage-sample.test.ts | 227 ++ .../playground/compiler-view/stage-sample.ts | 89 + .../stage-sample/document-samples.ts | 128 + .../compiler-view/stage-sample/excerpt.ts | 57 + .../stage-sample/graph-samples.ts | 106 + .../compiler-view/stage-sample/not-reached.ts | 19 + .../compiler-view/stage-sample/rows.ts | 54 + .../stage-sample/trace-samples.ts | 136 + .../playground/compiler-view/stages.ts | 318 ++ .../compiler-view/strategy-table.tsx | 99 + .../playground/docs/code-block.css | 31 + .../playground/docs/code-block.test.tsx | 22 + .../playground/docs/code-block.tsx | 34 + .../playground/docs/documentation.tsx | 23 + .../playground/docs/example-pages.test.tsx | 38 + .../playground/docs/example-pages.ts | 10 + .../playground/docs/figures/figure.css | 16 + .../playground/docs/figures/figure.tsx | 18 + .../playground/docs/open-question.tsx | 16 + .../playground/docs/page-components.ts | 12 + .../playground/editor/line-decorations.ts | 36 + .../playground/editor/monaco-editor.tsx | 200 ++ .../playground/editor/monaco-languages.d.ts | 6 + .../playground/editor/monaco.ts | 41 + .../editor/petri-net-ir-language.ts | 87 + .../petri-net-ir-language/completions.test.ts | 111 + .../petri-net-ir-language/completions.ts | 226 ++ .../playground/editor/provenance-hover.ts | 79 + .../playground/editor/swiss-theme.ts | 70 + .../examples-view/compiler-options-panel.tsx | 107 + .../examples-view/diagnostics-list.tsx | 62 + .../examples-view/examples-view.tsx | 207 ++ .../playground/examples-view/file-list.tsx | 24 + .../playground/examples-view/hover.ts | 40 + .../examples-view/module-edits.test.ts | 37 + .../playground/examples-view/module-edits.ts | 37 + .../playground/examples-view/module-view.tsx | 120 + pocs/zeroth-playground/playground/index.html | 47 + pocs/zeroth-playground/playground/main.tsx | 16 + .../playground/options/option-edits.test.ts | 61 + .../playground/options/option-edits.ts | 41 + .../playground/options/option-labels.test.ts | 29 + .../playground/options/option-labels.ts | 133 + .../playground/pointer/pointer-motion.test.ts | 71 + .../playground/pointer/pointer-motion.ts | 38 + .../playground/pointer/use-dwell.ts | 42 + .../playground/preview/layout-net.ts | 153 + .../playground/preview/net-graph.test.ts | 115 + .../playground/preview/net-graph.ts | 124 + .../playground/preview/net-preview.tsx | 33 + .../preview/net-preview/arc-view.tsx | 46 + .../preview/net-preview/drawing.tsx | 67 + .../preview/net-preview/marking.tsx | 35 + .../preview/net-preview/node-view.tsx | 69 + .../playground/scripts/screenshot.ts | 54 + .../playground/theme/fonts.ts | 7 + .../playground/theme/hash-wordmark.tsx | 34 + .../playground/theme/theme.css | 1489 +++++++++ .../zeroth-playground/playground/ui/panel.tsx | 68 + .../playground/ui/resize-target.ts | 2 + .../playground/ui/switch.tsx | 26 + .../playground/ui/use-folds.ts | 61 + pocs/zeroth-playground/pnpm-lock.yaml | 2831 +++++++++++++++++ pocs/zeroth-playground/tsconfig.json | 22 + pocs/zeroth-playground/vite.config.ts | 58 + 251 files changed, 27645 insertions(+) create mode 100644 pocs/zeroth-playground/.gitignore create mode 100644 pocs/zeroth-playground/AGENTS.md create mode 100644 pocs/zeroth-playground/CLAUDE.md create mode 100644 pocs/zeroth-playground/README.md create mode 100644 pocs/zeroth-playground/compiler/README.md create mode 100644 pocs/zeroth-playground/compiler/boundary.test.ts create mode 100644 pocs/zeroth-playground/compiler/code/code-strings.test.ts create mode 100644 pocs/zeroth-playground/compiler/code/code-strings.ts create mode 100644 pocs/zeroth-playground/compiler/code/code-tree.ts create mode 100644 pocs/zeroth-playground/compiler/code/linear-code.test.ts create mode 100644 pocs/zeroth-playground/compiler/code/linear-code.ts create mode 100644 pocs/zeroth-playground/compiler/compile.test.ts create mode 100644 pocs/zeroth-playground/compiler/compile.ts create mode 100644 pocs/zeroth-playground/compiler/diagnostics.ts create mode 100644 pocs/zeroth-playground/compiler/emit/describe.test.ts create mode 100644 pocs/zeroth-playground/compiler/emit/describe.ts create mode 100644 pocs/zeroth-playground/compiler/emit/emit.test.ts create mode 100644 pocs/zeroth-playground/compiler/emit/emit.ts create mode 100644 pocs/zeroth-playground/compiler/emit/linear.test.ts create mode 100644 pocs/zeroth-playground/compiler/emit/linear.ts create mode 100644 pocs/zeroth-playground/compiler/emit/python-writer.test.ts create mode 100644 pocs/zeroth-playground/compiler/emit/python-writer.ts create mode 100644 pocs/zeroth-playground/compiler/emit/python.test.ts create mode 100644 pocs/zeroth-playground/compiler/emit/python.ts create mode 100644 pocs/zeroth-playground/compiler/emit/spn.test.ts create mode 100644 pocs/zeroth-playground/compiler/emit/spn.ts create mode 100644 pocs/zeroth-playground/compiler/graph/linear-graph.ts create mode 100644 pocs/zeroth-playground/compiler/graph/module-graph.ts create mode 100644 pocs/zeroth-playground/compiler/graph/spn-graph.ts create mode 100644 pocs/zeroth-playground/compiler/index.ts create mode 100644 pocs/zeroth-playground/compiler/ir/accessors.test.ts create mode 100644 pocs/zeroth-playground/compiler/ir/accessors.ts create mode 100644 pocs/zeroth-playground/compiler/ir/net-item.ts create mode 100644 pocs/zeroth-playground/compiler/ir/parse.test.ts create mode 100644 pocs/zeroth-playground/compiler/ir/parse.ts create mode 100644 pocs/zeroth-playground/compiler/ir/references.test.ts create mode 100644 pocs/zeroth-playground/compiler/ir/references.ts create mode 100644 pocs/zeroth-playground/compiler/ir/reserved-names.ts create mode 100644 pocs/zeroth-playground/compiler/ir/schema.ts create mode 100644 pocs/zeroth-playground/compiler/ir/yaml-keys.test.ts create mode 100644 pocs/zeroth-playground/compiler/ir/yaml-keys.ts create mode 100644 pocs/zeroth-playground/compiler/lower/clocks.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/clocks.ts create mode 100644 pocs/zeroth-playground/compiler/lower/clocks/refusals.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/clocks/refusals.ts create mode 100644 pocs/zeroth-playground/compiler/lower/colour-layout.ts create mode 100644 pocs/zeroth-playground/compiler/lower/guard-inputs.ts create mode 100644 pocs/zeroth-playground/compiler/lower/lower.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/lower.ts create mode 100644 pocs/zeroth-playground/compiler/lower/lowering.ts create mode 100644 pocs/zeroth-playground/compiler/lower/marking-terms.ts create mode 100644 pocs/zeroth-playground/compiler/lower/modular.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/modular.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic/bindings.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic/colour.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic/compaction.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic/dynamics.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic/kernels.ts create mode 100644 pocs/zeroth-playground/compiler/lower/monolithic/root-bindings.ts create mode 100644 pocs/zeroth-playground/compiler/lower/names.test.ts create mode 100644 pocs/zeroth-playground/compiler/lower/names.ts create mode 100644 pocs/zeroth-playground/compiler/lower/step-plan.ts create mode 100644 pocs/zeroth-playground/compiler/mapping.md create mode 100644 pocs/zeroth-playground/compiler/options.test.ts create mode 100644 pocs/zeroth-playground/compiler/options.ts create mode 100644 pocs/zeroth-playground/compiler/readme.test.ts create mode 100644 pocs/zeroth-playground/compiler/scripts/check-zrth.ts create mode 100644 pocs/zeroth-playground/compiler/scripts/construct_net.py create mode 100644 pocs/zeroth-playground/compiler/scripts/option-variants.test.ts create mode 100644 pocs/zeroth-playground/compiler/scripts/option-variants.ts create mode 100644 pocs/zeroth-playground/compiler/scripts/resolve-ts.ts create mode 100644 pocs/zeroth-playground/compiler/testing/birth-death.fixtures.ts create mode 100644 pocs/zeroth-playground/compiler/testing/code-builders.ts create mode 100644 pocs/zeroth-playground/compiler/testing/code-trees.json create mode 100644 pocs/zeroth-playground/compiler/testing/coloured-nets.fixtures.ts create mode 100644 pocs/zeroth-playground/compiler/testing/compile-net.ts create mode 100644 pocs/zeroth-playground/compiler/testing/fixture-parser.ts create mode 100644 pocs/zeroth-playground/compiler/testing/fork.fixtures.ts create mode 100644 pocs/zeroth-playground/compiler/testing/line-containing.ts create mode 100644 pocs/zeroth-playground/compiler/testing/random-net.ts create mode 100644 pocs/zeroth-playground/compiler/testing/raw-text.d.ts create mode 100644 pocs/zeroth-playground/compiler/testing/reference-step.ts create mode 100644 pocs/zeroth-playground/compiler/testing/run-linear-graph.ts create mode 100644 pocs/zeroth-playground/compiler/testing/small-nets.fixtures.ts create mode 100644 pocs/zeroth-playground/compiler/trace/describe-item.ts create mode 100644 pocs/zeroth-playground/compiler/trace/provenance.test.ts create mode 100644 pocs/zeroth-playground/compiler/trace/provenance.ts create mode 100644 pocs/zeroth-playground/compiler/trace/trace-ir.test.ts create mode 100644 pocs/zeroth-playground/compiler/trace/trace-ir.ts create mode 100644 pocs/zeroth-playground/compiler/tsconfig.json create mode 100644 pocs/zeroth-playground/examples/README.md create mode 100644 pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/capacity/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/conflict/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/cycle/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/fork-clocked/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/fork/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/queue/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.css create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.test.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/format.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.test.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.test.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/range-field.tsx create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/survival-plot.tsx create mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/use-width.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/meta.ts create mode 100644 pocs/zeroth-playground/examples/birth-death/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/birth-death/page.mdx create mode 100644 pocs/zeroth-playground/examples/boiler/meta.ts create mode 100644 pocs/zeroth-playground/examples/boiler/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/boiler/page.mdx create mode 100644 pocs/zeroth-playground/examples/bucket/meta.ts create mode 100644 pocs/zeroth-playground/examples/bucket/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/bucket/page.mdx create mode 100644 pocs/zeroth-playground/examples/cafe-queue/meta.ts create mode 100644 pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/cafe-queue/page.mdx create mode 100644 pocs/zeroth-playground/examples/capacity/meta.ts create mode 100644 pocs/zeroth-playground/examples/capacity/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/capacity/page.mdx create mode 100644 pocs/zeroth-playground/examples/catalog.test.ts create mode 100644 pocs/zeroth-playground/examples/catalog.ts create mode 100644 pocs/zeroth-playground/examples/conflict/meta.ts create mode 100644 pocs/zeroth-playground/examples/conflict/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/conflict/page.mdx create mode 100644 pocs/zeroth-playground/examples/cycle/components/one-step.css create mode 100644 pocs/zeroth-playground/examples/cycle/components/one-step.tsx create mode 100644 pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx create mode 100644 pocs/zeroth-playground/examples/cycle/meta.ts create mode 100644 pocs/zeroth-playground/examples/cycle/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/cycle/page.mdx create mode 100644 pocs/zeroth-playground/examples/drones/meta.ts create mode 100644 pocs/zeroth-playground/examples/drones/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/drones/page.mdx create mode 100644 pocs/zeroth-playground/examples/examples.test.ts create mode 100644 pocs/zeroth-playground/examples/fork-clocked/components/clock-race.css create mode 100644 pocs/zeroth-playground/examples/fork-clocked/components/clock-race.tsx create mode 100644 pocs/zeroth-playground/examples/fork-clocked/meta.ts create mode 100644 pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/fork-clocked/page.mdx create mode 100644 pocs/zeroth-playground/examples/fork/meta.ts create mode 100644 pocs/zeroth-playground/examples/fork/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/fork/page.mdx create mode 100644 pocs/zeroth-playground/examples/queue/components/coin-draws.css create mode 100644 pocs/zeroth-playground/examples/queue/components/coin-draws.tsx create mode 100644 pocs/zeroth-playground/examples/queue/meta.ts create mode 100644 pocs/zeroth-playground/examples/queue/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/queue/page.mdx create mode 100644 pocs/zeroth-playground/examples/sir-model/meta.ts create mode 100644 pocs/zeroth-playground/examples/sir-model/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/sir-model/page.mdx create mode 100644 pocs/zeroth-playground/package.json create mode 100644 pocs/zeroth-playground/playground/README.md create mode 100644 pocs/zeroth-playground/playground/app/app.tsx create mode 100644 pocs/zeroth-playground/playground/app/document.test.ts create mode 100644 pocs/zeroth-playground/playground/app/document.ts create mode 100644 pocs/zeroth-playground/playground/app/example-picker.tsx create mode 100644 pocs/zeroth-playground/playground/app/header.tsx create mode 100644 pocs/zeroth-playground/playground/app/route.test.ts create mode 100644 pocs/zeroth-playground/playground/app/route.ts create mode 100644 pocs/zeroth-playground/playground/app/view-switch.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx create mode 100644 pocs/zeroth-playground/playground/compiler-view/compiler-view.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/focus-handoff.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-edge.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-node.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-layout.test.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-layout.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/rich-text.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-focus.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/graph-samples.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/not-reached.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/rows.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/trace-samples.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/stages.ts create mode 100644 pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx create mode 100644 pocs/zeroth-playground/playground/docs/code-block.css create mode 100644 pocs/zeroth-playground/playground/docs/code-block.test.tsx create mode 100644 pocs/zeroth-playground/playground/docs/code-block.tsx create mode 100644 pocs/zeroth-playground/playground/docs/documentation.tsx create mode 100644 pocs/zeroth-playground/playground/docs/example-pages.test.tsx create mode 100644 pocs/zeroth-playground/playground/docs/example-pages.ts create mode 100644 pocs/zeroth-playground/playground/docs/figures/figure.css create mode 100644 pocs/zeroth-playground/playground/docs/figures/figure.tsx create mode 100644 pocs/zeroth-playground/playground/docs/open-question.tsx create mode 100644 pocs/zeroth-playground/playground/docs/page-components.ts create mode 100644 pocs/zeroth-playground/playground/editor/line-decorations.ts create mode 100644 pocs/zeroth-playground/playground/editor/monaco-editor.tsx create mode 100644 pocs/zeroth-playground/playground/editor/monaco-languages.d.ts create mode 100644 pocs/zeroth-playground/playground/editor/monaco.ts create mode 100644 pocs/zeroth-playground/playground/editor/petri-net-ir-language.ts create mode 100644 pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.test.ts create mode 100644 pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.ts create mode 100644 pocs/zeroth-playground/playground/editor/provenance-hover.ts create mode 100644 pocs/zeroth-playground/playground/editor/swiss-theme.ts create mode 100644 pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx create mode 100644 pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx create mode 100644 pocs/zeroth-playground/playground/examples-view/examples-view.tsx create mode 100644 pocs/zeroth-playground/playground/examples-view/file-list.tsx create mode 100644 pocs/zeroth-playground/playground/examples-view/hover.ts create mode 100644 pocs/zeroth-playground/playground/examples-view/module-edits.test.ts create mode 100644 pocs/zeroth-playground/playground/examples-view/module-edits.ts create mode 100644 pocs/zeroth-playground/playground/examples-view/module-view.tsx create mode 100644 pocs/zeroth-playground/playground/index.html create mode 100644 pocs/zeroth-playground/playground/main.tsx create mode 100644 pocs/zeroth-playground/playground/options/option-edits.test.ts create mode 100644 pocs/zeroth-playground/playground/options/option-edits.ts create mode 100644 pocs/zeroth-playground/playground/options/option-labels.test.ts create mode 100644 pocs/zeroth-playground/playground/options/option-labels.ts create mode 100644 pocs/zeroth-playground/playground/pointer/pointer-motion.test.ts create mode 100644 pocs/zeroth-playground/playground/pointer/pointer-motion.ts create mode 100644 pocs/zeroth-playground/playground/pointer/use-dwell.ts create mode 100644 pocs/zeroth-playground/playground/preview/layout-net.ts create mode 100644 pocs/zeroth-playground/playground/preview/net-graph.test.ts create mode 100644 pocs/zeroth-playground/playground/preview/net-graph.ts create mode 100644 pocs/zeroth-playground/playground/preview/net-preview.tsx create mode 100644 pocs/zeroth-playground/playground/preview/net-preview/arc-view.tsx create mode 100644 pocs/zeroth-playground/playground/preview/net-preview/drawing.tsx create mode 100644 pocs/zeroth-playground/playground/preview/net-preview/marking.tsx create mode 100644 pocs/zeroth-playground/playground/preview/net-preview/node-view.tsx create mode 100644 pocs/zeroth-playground/playground/scripts/screenshot.ts create mode 100644 pocs/zeroth-playground/playground/theme/fonts.ts create mode 100644 pocs/zeroth-playground/playground/theme/hash-wordmark.tsx create mode 100644 pocs/zeroth-playground/playground/theme/theme.css create mode 100644 pocs/zeroth-playground/playground/ui/panel.tsx create mode 100644 pocs/zeroth-playground/playground/ui/resize-target.ts create mode 100644 pocs/zeroth-playground/playground/ui/switch.tsx create mode 100644 pocs/zeroth-playground/playground/ui/use-folds.ts create mode 100644 pocs/zeroth-playground/pnpm-lock.yaml create mode 100644 pocs/zeroth-playground/tsconfig.json create mode 100644 pocs/zeroth-playground/vite.config.ts diff --git a/README.md b/README.md index 9cb23b13..b88de7c5 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ The `pocs` folder contains **proof of concepts** and other one-off experiments. - [`distributed_collab`](pocs/distributed_collab) - **Distributed Collab**: a BEAM VM based system for publishing/subscribing to JSON Patches with core logic implemented in Rust - [`hash-agents`](pocs/hash-agents) - **HASH Agents**: an experimental setup for writing Python-based 'agents' that interface with LLMs - [`hash_helm_chart`](pocs/hash_helm_chart) - **HASH Helm Charts**: An experimental [Helm](https://helm.sh) chart for deploying (now outdated, legacy) instances of HASH on Kubernetes +- [`zeroth-playground`](pocs/zeroth-playground) - **Zeroth Playground**: a compiler from Petri nets to Zeroth reactive modules, with a playground that shows each net beside the Python it compiles to A number of older POCs can be found in our `hasharchives` organization, including: - [`wasm-ts-esm-in-node-jest-and-nextjs`](https://github.com/hasharchives/wasm-ts-esm-in-node-jest-and-nextjs) - A **Wasm + TypeScript + ESM in Node.js, Jest and Next.js 13** example project diff --git a/pocs/zeroth-playground/.gitignore b/pocs/zeroth-playground/.gitignore new file mode 100644 index 00000000..d7a06970 --- /dev/null +++ b/pocs/zeroth-playground/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +screenshots/ +*.log +.DS_Store +*.tmp.ts diff --git a/pocs/zeroth-playground/AGENTS.md b/pocs/zeroth-playground/AGENTS.md new file mode 100644 index 00000000..669fde23 --- /dev/null +++ b/pocs/zeroth-playground/AGENTS.md @@ -0,0 +1,44 @@ +# AGENTS.md + +This repository holds a compiler from Petri nets to Zeroth reactive modules, the examples it is shown on, and a playground for both. The input is a Petri net written in YAML as the Petri net IR. The output is Python that builds the modules with `zrth.sugar`, the Python DSL of Zeroth's reactive-modules library. The playground shows the IR, the Python, a preview of the net and a documentation page per example, and builds to one HTML file. + +| Folder | Holds | +| --- | --- | +| `compiler/` | the compiler, with its public API in `index.ts`. It imports nothing outside the folder but js-yaml and zod | +| `examples/` | one folder per example net, and the catalog that collects them | +| `playground/` | the React app, which imports only `compiler/index.ts` and the examples | + +The compiler was copied from the HASH monorepo (hashintel/hash), and this copy is the one to change. The target DSL is `python/zrth/sugar.py` in [zeroth-research/reactive-modules](https://github.com/zeroth-research/reactive-modules), on its `spn` branch. That branch names the step method `next`; its `main` branch accepts only `update`. + +## Commands + +```bash +pnpm install # Node 24 or later +pnpm exec playwright install chromium # once, for pnpm screenshot and the headless checks +pnpm dev # http://localhost:5173, `#` opens an example +pnpm lint:tsc # type-check, then check compiler/ on its own +pnpm test # unit tests (vitest) +pnpm build # dist/index.html, one self-contained file +pnpm screenshot # the listed examples and the Compiler view, into screenshots/ +ZRTH_PATH=/path/to/zrth pnpm check:zrth # import every example's Python into zrth; not a gate +``` + +## Docs + +| File | Read it for | +| --- | --- | +| [compiler/README.md](compiler/README.md) | what the compiler reads and writes, one step of the net, the pipeline, the options and diagnostics, the code-parser hook, extending it | +| [compiler/mapping.md](compiler/mapping.md) | the design shared by HASH and Zeroth: each mapping, its options, the open questions | +| [examples/README.md](examples/README.md) | the example ladder, an example's folder and options, adding an example and its page | +| [playground/README.md](playground/README.md) | the playground's folders, data flow and conventions, the single-file budget, the gates, the tests, checking the UI headlessly | + +## Rules + +- Settling a question in [compiler/mapping.md](compiler/mapping.md), on either side, updates it there in the same change. +- Keep the build to one HTML file that opens from `file://`. Measure the bundle before adding a dependency. +- Write React as described in [playground/README.md](playground/README.md#conventions): `React.FC` constants with no React type imports, values derived in render, effects only for systems React does not own. `pnpm build` fails on every React Compiler diagnostic. +- Write every test as GIVEN, WHEN, THEN, as [playground/README.md](playground/README.md#tests) describes. +- Run `pnpm lint:tsc`, `pnpm test` and `pnpm build` before each commit. Check visible changes headlessly, as described in [playground/README.md](playground/README.md#checking-the-ui). +- When these docs do not settle a decision, take the simplest option and say so in the commit message. +- Commit messages: an imperative summary of the visible effect, then a body with the choices made. +- A change to the structure, the pipeline or the example workflow updates the matching README in the same commit. diff --git a/pocs/zeroth-playground/CLAUDE.md b/pocs/zeroth-playground/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/pocs/zeroth-playground/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/pocs/zeroth-playground/README.md b/pocs/zeroth-playground/README.md new file mode 100644 index 00000000..0f6a30b9 --- /dev/null +++ b/pocs/zeroth-playground/README.md @@ -0,0 +1,32 @@ +# zeroth-playground + +This repository compiles a Petri net into Zeroth reactive modules, so that [zrth](https://github.com/zeroth-research/reactive-modules), Zeroth's reactive-modules library, can run and check it. The input is a net written in YAML as the Petri net IR; the output is Python that builds the modules with `zrth.sugar`. + +The playground shows the compiler at work on one screen: an example's documentation on the left, its IR in the middle, the Python module on the right and a preview of the net under them. It builds to one HTML file that opens from disk. + +## Running + +It needs Node 24 or later and pnpm. + +```bash +pnpm install +pnpm dev +``` + +`pnpm build` writes `dist/index.html`, one file with the JavaScript, the CSS, the fonts (Inter and JetBrains Mono) and the editor worker inlined, about 6.6 MB. It opens from `file://` and can be sent as an attachment. A comment at its top names every package and font it bundles, with the licence and the source of each. A URL hash names the example to open: `index.html#birth-death`. + +`pnpm test` runs the unit tests, `pnpm lint:tsc` type-checks, and `pnpm screenshot` opens the built file in Playwright's Chromium and screenshots the examples the picker lists, then the Compiler view, at 1440×900 into `screenshots/`. Install that Chromium once with `pnpm exec playwright install chromium`. + +## What it does + +- **Views.** The IR editor has highlighting, completion of the IR's keys and places, and a hover that reads the IR trace. The module view lists the Python files and the diagnostics, each at the IR line of its item. The preview draws the net as an SVG laid out with elkjs. +- **Options beside the IR.** The Compiler options panel under the IR editor holds the compiler options; the IR carries none. An option that does not apply to the net is greyed, with the reason. Each example opens with its own options, and Reset restores them with the text. +- **Cross-highlighting.** Hovering a line in either editor lights the lines on the other side that name the same net item, and the item in the preview. Hovering a place or a transition in the preview lights its lines on both sides. +- **The example ladder.** The examples climb from plain nets (Steps) to stochastic ones (Rates), each named by the feature it tackles. The picker in the header lists them. Each has a page; some end with open questions, each naming who can settle it: HASH, Zeroth or both. Two pages carry a CSS animation, and the Birth–death page has an explorer with a Δt slider, a draw and the coin's rate. +- **The Compiler view.** A switch in the header opens a guide, a graph of the pipeline's stages and a card per stage with a live sample from the open example. `#compiler`, or `#compiler/queue`, opens it. + +The coloured examples (boiler, drones, bucket) carry code strings and are refused with `code-not-parsed`: reading the code back needs a parser built on the TypeScript compiler, far past the single-file budget. The picker leaves them out; `#boiler`, `#drones` and `#bucket` open them, with the refusal and the page. + +## Working on it + +[AGENTS.md](AGENTS.md) is the entry point for people and agents: commands, rules, and links to the READMEs of [compiler/](compiler/README.md), [examples/](examples/README.md) and [playground/](playground/README.md), and to [compiler/mapping.md](compiler/mapping.md), the design shared with Zeroth. diff --git a/pocs/zeroth-playground/compiler/README.md b/pocs/zeroth-playground/compiler/README.md new file mode 100644 index 00000000..8fb7c0fa --- /dev/null +++ b/pocs/zeroth-playground/compiler/README.md @@ -0,0 +1,382 @@ +# Compiler + +Compiles a Petri net, written in YAML as the Petri net IR, into Python that builds Zeroth reactive modules with `zrth.sugar`. Each module is a `Module` subclass with an `init` and a `next` method, plus a `flow` method under clock rates, and the modules compose into one system. The compiler is synchronous, has no React, and imports only js-yaml and zod. + +`index.ts` is the public API: the playground and the examples import nothing else from this folder. [mapping.md](mapping.md) is the design shared by HASH and Zeroth: each mapping, the options considered for it, and the open questions. + +## Use + +```ts +import { compile } from "./compiler"; + +const { files, errors, warnings } = compile(irText, { options: { shape: "modular" } }); +``` + +`compile(text, { options, parseCode })` runs the whole pipeline. `options` are the compiler options asked for; the document carries none. `parseCode` reads code strings into code trees (see [The code-parser hook](#the-code-parser-hook)). It returns a `Compilation`: + +| Field | Holds | +| --- | --- | +| `ir` | the parsed document, or `null` when the text is not an IR | +| `options` | the options in force: those asked for that apply to the net, the defaults for the rest | +| `irTrace` | line ranges over the IR text, each naming its net item; empty without a document | +| `graph` | the module graph, or `null` when parsing or lowering refuses | +| `files` | the Python files, `net.py` first, each with its trace; empty when refused | +| `errors` | why the text is not an IR, or why the net cannot be lowered, each with its IR line when one is found | +| `warnings` | one `option-not-applicable` per option asked for that does not apply to the net | + +## Input and output + +The cycle example: one token moving between two places. + +```yaml +name: cycle +kind: plain + +places: + A: + B: + +transitions: + Go: + inputs: + A: + outputs: + B: + Back: + inputs: + B: + outputs: + A: + +marking: + A: 1 +``` + +Under the default options it compiles to one module whose `next` is one step of the net: + +```python +from zrth import LIA, Int, Var +from zrth.sugar import Module, ite + +INT = Int([1, 1]) + +A = Var(INT) +B = Var(INT) + + +class Cycle(Module): + """Plain Petri net with 2 places and 2 transitions. One next is one step of the net.""" + + def init(self): + return 1, 0 + + def next(self, A, B): + # sweep in order; a firing consumes its input tokens at once + fire_Go = A >= 1 # Go: A -> B + A = ite(fire_Go, A - 1, A) + fire_Back = B >= 1 # Back: B -> A + B = ite(fire_Back, B - 1, B) + # end of step: produced tokens land + A = ite(fire_Back, A + 1, A) + B = ite(fire_Go, B + 1, B) + return A, B + + +net = Cycle(theory=LIA, ctrl=(A, B)) +``` + +With `shape: "modular"` the same net becomes four modules joined by `compose`: `Transition_Go` and `Transition_Back` drive the Bools `fire_Go` and `fire_Back`, and `Place_A` and `Place_B` read them through `X(...)`. + +| Term | Meaning | +| --- | --- | +| Petri net IR | the YAML document the compiler reads; `PetriNetIr`, inferred from the schema in `ir/schema.ts` | +| reactive module | a Zeroth component with variables, an `init` and a `next` method | +| lowering | IR to module graph, in `lowerPetriNetIr`; refusals of a net happen here | +| module graph | the compiler's intermediate form: linear (LIA or LRA) or SPN | +| coins | rates tested each step against a uniform draw, over the step length `dt` | +| clocks | rates as exponential clocks in continuous time, in Zeroth's SPN theory | +| shape | monolithic: one module; modular: one module per transition and per place, composed | +| trace, provenance | line ranges of the IR or the Python, each naming the net item it comes from | +| code strings | guards, rates, kernels and dynamics written as code, read through the code-parser hook | +| LIA, LRA | Zeroth's linear integer and linear real arithmetic theories | +| SPN | Zeroth's stochastic Petri net theory: clocks, events and `Nat` counters | +| `ite`, `X` | if-then-else, and the value a variable takes this step | + +## One step of the net + +Under coin rates, the default, each call of `next` is one step of the net. Every lowering under coins writes this step, and `testing/reference-step.ts` states it as the oracle the tests compare against. + +1. **Dynamics first.** Every real attribute of every token in a place with dynamics takes one Euler step of `dt`. +2. **Sweep in record order.** The transitions are tried once each, in the order of the `transitions` record. +3. **Enabling.** A transition fires when: + - each place of a standard or read arc holds at least the arc's weight, and each place of an inhibitor arc holds fewer; + - its guard holds; + - each capped place it adds to has room for the net change (below); + - for a transition with a rate, its draw passes the coin test (below); + - under `control: open`, a controllable transition's external choice `go_T` is true; + - under `conflicts: nondet`, a transition that shares an input place with another has its pick `pick_T` true. +4. **Consumption is immediate.** A firing takes its input tokens at once, so a later transition in the sweep sees what is left. A read arc takes nothing. +5. **Production lands at the end of the step.** A token produced in a step cannot be taken in the same step. +6. **Capped places track their fill.** `fill_P` starts at the place's count, and every firing that moves tokens in the place updates it. A producer fires only if `fill_P` plus its net change stays within the capacity, so it sees the tokens an earlier producer will land. +7. **Coins.** A transition with the constant rate r reads a uniform draw `u_T`, an input of the module, and passes when the draw is at least e^(-r·dt): it fires within the step with the probability that an exponential delay at rate r ends within `dt`. A rate written as code is tested against an exponential draw instead, `rate >= X(e_T)`. + +A coloured place is a row of positions, as many as its `capacity` or the `slots` option. Its tokens fill a prefix of the row, so `input.P[k]` is the k-th token. A transition tries the combinations of the tokens its arcs bind in binding order: token indices ascending per arc, the combinations in lexicographic order with the last arc advancing fastest. The first combination whose guard holds fires, and its kernel writes the produced tokens. At the end of the step the surviving tokens close up in position order, and the produced tokens land after them in sweep order. A token that finds no free position sets the place's sticky `overflow_P` flag and is dropped. + +The modular shape writes the same step as one module per transition and one per place. A transition module awaits the firing flags of the earlier transitions that take from its input places, or move tokens in a capped place it adds to, so it sees the marking the sweep would show it. `lower/lower.test.ts` runs both shapes, under six option sets, on 150 random plain and stochastic nets with weighted, read and inhibitor arcs, for 25 steps each, and compares every step with the reference step. + +Under clock rates (`rates: "clock"`) the net runs in continuous time in Zeroth's SPN theory, and there is no step length. Each transition owns a clock armed with an exponential delay at its rate. The clock runs down against the time reference `t` while the transition's input arcs allow a firing. Time flows until the first clock reaches zero; that transition fires, toggles its event and re-arms its clock, and each place it touches moves one token. Under `conflicts: nondet` a transition in a conflict also needs its pick at the expiry; with the pick false, its clock stays at zero. + +## Pipeline + +``` +IR text + │ ir/parse.ts: YAML, the schema, the checks across sections + ▼ +PetriNetIr ──► trace/trace-ir.ts ──► IR trace ──► a line for each diagnostic + │ options.ts: keep the options that apply, warn about the rest + ▼ +lower/lower.ts: refuse, plan the step, lower it ◄── parseCode, through code/ + │ + ▼ +ModuleGraph ──► emit/emit.ts ──► Python files, each with its trace +``` + +| Stage | Where | +| --- | --- | +| Parse YAML, check the schema | `parsePetriNetIr` in `ir/parse.ts`, over the zod schema in `ir/schema.ts`, its checks across sections in `ir/references.ts`, and the key lines from `ir/yaml-keys.ts` | +| Keep and resolve the options | `optionsForNet` in `options.ts` keeps the options that apply, `droppedOptions` warns about the rest with `option-not-applicable`, and `resolveOptions` fills in the defaults | +| Trace the IR | `traceIr(ir, irText, options)` in `trace/trace-ir.ts`, over `yamlKeys`; the words for a place, a transition and an arc, shared with the Python's trace, are in `trace/describe-item.ts` | +| Lower to a module graph | `lowerPetriNetIr(ir, options, parseCode)` in `lower/lower.ts`: the chosen lowering refuses what it cannot express, then the step is planned (`planStep` in `lower/step-plan.ts`) and lowered. Refusals come back as errors; the lowering raises no warnings | +| Emit Python | `emitPython(graph, layout, ir)` in `emit/emit.ts`. The graph's dialect, `emit/linear.ts` (LIA, LRA) or `emit/spn.ts` (clocks), prints the expressions, imports and sort constants as a `PythonProgram`. `pythonFiles` in `emit/python.ts` writes the rest the same way for both: declarations, classes, instances, the composition, and one file or one per module as the layout says. Each line is written with its provenance through `emit/python-writer.ts`, in the words of `emit/describe.ts`, so each file comes with its trace | +| Locate diagnostics | `compile` gives each error and warning the line `firstLineOf` finds in the IR trace (`trace/provenance.ts`) | + +## Folders + +| Folder | Holds | +| --- | --- | +| `ir/` | the schema and the types inferred from it; the checks across sections (`references.ts`); the names the Python claims and the net's class name (`reserved-names.ts`); the accessors for bare keys; `NetItem`, the item a diagnostic or a traced line names; the parser; and the YAML key scan the parser and the IR trace share | +| `code/` | the parser hook's contract (`code-tree.ts`); `codeStrings`, every code string of a document with its item and surface; and `linear-code.ts`, which reads a code tree as a linear expression, computing constant arithmetic, or throws a `Refusal` | +| `lower/` | `lower.ts`, which picks a lowering, lets it refuse, plans the step and lowers it. Each lowering is a `Lowering` (`lowering.ts`): `monolithic.ts` with its parts in `monolithic/`, `modular.ts`, and `clocks.ts` with its refusals in `clocks/refusals.ts`. `names.ts` coins every identifier the lowerings write, each prefix beside its reading, and reads them back with `describeName` for the traces | +| `graph/` | the module graphs the lowerings build: linear (LIA, LRA) and SPN | +| `emit/` | `python.ts`, the Python both graphs print to; one dialect per graph, `linear.ts` and `spn.ts`; `python-writer.ts`, which writes lines and blocks with their provenance and reads the trace off them; and `describe.ts`, the words for each kind of line | +| `trace/` | the trace over the IR text, the words for an item that both traces use, and the queries over a trace: `provenanceAt`, `matchingLines`, `linesOfItem`, `firstLineOf` | +| `testing/` | test support, never imported by the compiler: fixtures, the fixture code parser, `compileNet` and `yaml` to compile an IR object, a graph interpreter, random nets and the reference step | +| `scripts/` | tooling that runs under Node, never imported by the compiler: the zrth check (`check-zrth.ts`), the option sets it compiles under (`option-variants.ts`), the Python that imports a compiled net (`construct_net.py`), and the hook that lets Node resolve the compiler's imports (`resolve-ts.ts`) | + +At the root, `compile.ts` runs the pipeline, `options.ts` holds the options table, and `diagnostics.ts` holds `DIAGNOSTIC_CODES`, the `Refusal` that code and lowering helpers throw, and `within`, which says which code string a refusal comes from. + +`compiler/tsconfig.json` sets `rootDir` to this folder, so `tsc -p compiler`, run by `pnpm lint:tsc`, fails on an import that leaves it. It leaves `scripts/` to the root tsconfig, which has Node's types. `boundary.test.ts` checks the rest of the boundary: no package but js-yaml and zod (and vitest in the tests, Node's built-in modules in `scripts/`), no import of `testing/` or `scripts/` from the code the compiler runs, and no file that names the application the compiler was copied from. + +## Options + +The options are passed beside the IR, never written in it, so the same document compiles differently under different options. `OPTIONS` in `options.ts` is the one table: per option, its values (the default first, or `null` for a positive number), its default, what it decides, the nets it applies to, and `notApplicable(net, options)`, the reason it has no effect on a net. `CompilerOptions` and `ResolvedOptions` are derived from it. + +An option asked for that does not apply to the net is dropped with an `option-not-applicable` warning, and the net compiles as if it had not been asked for. Clock rates turn off the options that need a step; `layout` also reads `shape`. Whether a net is stochastic is read off its rates, not its `kind`, and the schema checks that the two agree. + +`pnpm test` checks this table against `OPTIONS`, in order. + + + +| Option | Values, default first | Applies to | Decides | +| --- | --- | --- | --- | +| `shape` | `monolithic`, `modular` | every net under coins | Monolithic: one module holds the whole step. Modular: one module per transition drives a firing flag, one module per place awaits the flags of its transitions, and the modules are composed. | +| `rates` | `coin`, `clock` | a stochastic net without colours or dynamics | Coin: a rate is tested against a uniform draw each step of dt, in a linear theory. Clock: in the SPN theory, each transition owns a clock armed with exp(rate) and an event it toggles when it fires, each place is a Nat counter, and time is continuous. | +| `conflicts` | `sweep`, `nondet` | a net where two transitions share an input place | Sweep: transitions that share an input place fire in record order, and a later one reads the marking the earlier ones left. Nondet: each transition in a conflict also waits for a Bool pick that nothing drives, so a proof ranges over every way the conflict resolves. | +| `marking` | `real`, `int` | a stochastic uncoloured net under coins | Real: every place is a Real and each guard tests its own draw. Int: places are Int, and one LRA module per transition turns its draw into a Bool flag the guard reads. | +| `control` | `closed`, `open` | a net with a controllable transition, under coins | Closed: a controllable transition fires whenever it is enabled. Open: it also waits for a Bool choice from outside, so the module is open to a controller. | +| `dt` | `1`, or any positive number | a net with rates or dynamics, under coins | The step length: a rate is tested over it, and dynamics take one Euler step of it. | +| `slots` | `8`, or any positive number | a coloured net | The slots a coloured place without a capacity gets. A produced token that finds no free slot sets the place's overflow flag. | +| `layout` | `single`, `per-module` | a net under the modular shape or clock rates | Single: one Python file holds the variables, the modules and the system. Per-module: each module class has a file of its own, and net.py declares the variables, imports the modules and composes them. | + + + +| Function | Gives | +| --- | --- | +| `resolveOptions(options)` | every option: the value given, or the default | +| `optionStates(ir, options)` | each option's value in force, its values, and why it does not apply: the playground panel's rows | +| `optionsForNet(options, ir)` | the options that apply and are off their default: what the panel stores and what `compile` keeps | +| `droppedOptions(options, ir)` | one `option-not-applicable` warning per option asked for that `optionsForNet` drops; internal, called by `compile` | + +## Diagnostics + +A diagnostic is `{ code, message, item, line? }`. `item` is the net item it concerns, a kind and an IR name. `line` is the 1-based IR line of that item, which `compile` adds from the IR trace. `errors` and `warnings` are the two channels. `Diagnostic.code` is typed by `DIAGNOSTIC_CODES` in `diagnostics.ts`, so a misspelt code fails `pnpm lint:tsc`. + +`pnpm test` checks these tables against `DIAGNOSTIC_CODES`, in order and word for word. + + + +### The text is not an IR + +Errors from `parsePetriNetIr`, at parse time. The compilation has no document and no IR trace. + +| Code | Meaning | +| --- | --- | +| `yaml-syntax` | The text is not YAML. | +| `schema` | The document does not fit the IR's shape. | +| `unknown-place` | An arc or the marking names a place that is not declared. | +| `unknown-colour` | A place or a dynamics entry names a colour that is not declared. | +| `unknown-dynamics` | A place names dynamics that are not declared. | +| `reserved-name` | An IR name, or the net's class name, is one the generated Python uses. | +| `marking-invalid` | A plain place starts with something other than a whole count, or a coloured one without a token list. | +| `marking-over-capacity` | A place starts with more tokens than its capacity. | +| `kind-mismatch` | The net's kind disagrees with its rates. | + +### An option is dropped + +The one warning, from `droppedOptions`, on the net. + +| Code | Meaning | +| --- | --- | +| `option-not-applicable` | An option asked for does not apply to the net and is ignored. | + +### A lowering cannot express the net + +Errors on the item a lowering cannot express. `modular-coloured-not-lowered` and the `clocks-*` codes come from a lowering's `refusals`, before the step is planned, so a net refused by them has no code read and none of the code refusals below. The others are raised as the step is planned or lowered. + +| Code | Meaning | +| --- | --- | +| `modular-coloured-not-lowered` | The modular shape is not lowered for coloured places or dynamics. | +| `clocks-capacity` | Clock rates cannot test a capacity. | +| `clocks-plain-transition` | Clock rates need a rate on every transition. | +| `clocks-rate-code` | Clock rates need a constant rate. | +| `clocks-rate-not-positive` | Clock rates need a positive rate. | +| `clocks-guard` | Clock rates cannot test a guard. | +| `clocks-kernel` | Clock rates move plain tokens and run no kernel. | +| `clocks-arc-weight` | Clock rates move one token per arc. | +| `code-not-parsed` | A code string could not be read into a tree; no parser was given, or it returned none. | +| `marking-exceeds-slots` | A coloured place starts with more tokens than it has slots. | +| `kernel-missing` | A transition produces coloured tokens without a kernel. | +| `binding-explosion` | A transition has too many token combinations to try. | + +### Code outside the linear theories + +Errors from reading a code tree, in `code/linear-code.ts` and `lower/monolithic/`, on the transition or place the code belongs to. The message says which code it comes from: "In the guard, ...". + +| Code | Meaning | +| --- | --- | +| `nonlinear-product` | Two values the tokens decide are multiplied. | +| `nonlinear-division` | A value is divided by one that is not a non-zero constant. | +| `nonlinear-power` | A value is raised to a power or taken modulo another. | +| `nonlinear-math` | A Math function has no linear form. | +| `math-random` | Math.random cannot run in a module. | +| `non-finite-constant` | Infinity or NaN has no value in the linear theories. | +| `distribution-unsupported` | A draw cannot become an input the harness draws. | +| `sort-mismatch` | A number, boolean or string is used where another sort is needed. | +| `string-as-value` | A string literal is used other than in a comparison with a string attribute. | +| `string-codes-differ` | Two string attributes with different values are compared. | +| `string-code-unknown` | A kernel writes a string the attribute does not take. | +| `token-as-value` | A whole token is used as a value. | +| `unbound-local` | The code reads a name it does not define. | +| `unknown-field` | A field is read from a value that is not a token. | +| `unknown-attribute` | The token's colour has no such attribute. | +| `unknown-length` | A length is read from something other than an input place. | +| `attribute-not-lowerable` | The code reads an attribute the theories cannot hold: a uuid or an open string. | +| `array-in-expression` | An array or a record is used as a value. | +| `kernel-output-shape` | A kernel's result is not a record of token lists keyed by output place. | +| `kernel-output-missing` | A kernel writes nothing into a coloured output place. | +| `kernel-output-count` | A kernel writes a different number of tokens than the arc carries. | +| `kernel-attribute-missing` | A produced token lacks an attribute of its colour. | +| `dynamics-shape` | Dynamics are not a map of the tokens to a record of derivatives. | + + + +## The code-parser hook + +Guards, rates, kernels and dynamics can be written as code in the IR. The compiler does not parse TypeScript: `CompileSettings.parseCode`, a `CodeParser`, reads each code string into a code tree. The contract, in `code/code-tree.ts`: + +- **Input.** One code string and its surface: `lambda` for a guard or a rate, `kernel`, or `dynamics`. The string is a bare body of TypeScript ending in `return`, with `input` (or `tokens` for dynamics) ambient and every constant already inlined. +- **Output.** A `CodeFunction` built from 18 node kinds: `numberLit`, `boolLit`, `stringLit`, `constant`, `localRef`, `fieldAccess`, `indexAccess`, `length`, `unary`, `binary`, `cond`, `let`, `mathCall`, `recordLit`, `arrayLit`, `arrayMap`, `distribution` and `distributionMap`. The tree carries no ids, spans or types. +- **Refusal.** `undefined` refuses the item with `code-not-parsed`. A construct outside the 18 kinds is the parser's to refuse, by returning `undefined`. +- **No constants to compute.** `code/linear-code.ts` computes constant `+`, `-`, `*` and `/` itself, so `(1 + 1) * x` is a scaling of `x`. +- **Pure and synchronous.** The playground compiles during render. A parser does not throw; a throw is a bug, not a refusal. + +Without a parser, a net with code strings is refused with `code-not-parsed`, one error per code string. `codeStrings(ir)` lists a document's code strings with their item, field and surface: the dynamics first, then each transition's guard, rate and kernel. They are the strings the lowering hands the parser. + +`linear-code.ts` translates the linear subset: `+`, `-`, scaling and division by a constant, comparisons, boolean operators, `?:`, `Math.max`, `Math.min` and `Math.abs`, string equality by code, `input.P.length`, and Gaussian and uniform draws as inputs. Anything else is refused with one of the codes above. + +## Traces + +A trace is a list of line ranges, each with a `Provenance`: `what` the lines are, `why` they are there, the `ir` path they come from, and the `source` net item. Ranges nest, so a line's record is the innermost range holding it. The emitter writes each Python line with its provenance, so the Python trace matches the text by construction. + +| Function | Gives | +| --- | --- | +| `provenanceAt(trace, line)` | the record of the innermost range holding the line | +| `matchingLines(trace, provenance)` | the ranges with the same source item, or, where neither has one, whose IR paths share a prefix: what a hover on one side lights on the other | +| `linesOfItem(trace, item)` | the ranges whose source is the item | + +## Tests + +Tests sit beside the files they test, and every case reads as GIVEN, WHEN, THEN. `testing/` holds what they share: + +| File | Holds | +| --- | --- | +| `compile-net.ts` | `compileNet`, `pythonOf` and `yaml`, which compile an IR object through its YAML text | +| `fixture-parser.ts` | a `CodeParser` that looks each code string up in `code-trees.json` instead of parsing it. The trees were captured once from a TypeScript parser; a test with a new code string adds its tree by hand, or builds a tree with `code-builders.ts` | +| `line-containing.ts` | `lineContaining`, the number of the first line of a text that contains a substring, for the trace tests | +| `run-linear-graph.ts`, `reference-step.ts`, `random-net.ts` | the interpreter, the oracle and the seeded nets behind the differential test in `lower/lower.test.ts` | +| `*.fixtures.ts` | nets: small nets, birth-death, fork, and the coloured Bucket, Boiler and Drones; and the expected Python of birth-death and the Bucket | + +`readme.test.ts` checks the options and diagnostics tables of this file, and `boundary.test.ts` the folder's imports and names. The examples in `../examples/` are compiled by their own tests. + +## Checking the output against zrth + +`pnpm check:zrth` checks that the Python constructs under [zrth](https://github.com/zeroth-research/reactive-modules), Zeroth's reactive-modules library. It needs a zrth checkout with its Python package built. Install a Rust toolchain, [uv](https://github.com/astral-sh/uv) and [just](https://just.systems/); the package needs Python 3.12 or 3.13. Then: + +```bash +git clone https://github.com/zeroth-research/reactive-modules +cd reactive-modules +git checkout spn # 5bd3bf9 is the commit checked here +just py-rebuild # builds the Rust core and the Python package +cd - +ZRTH_PATH=/path/to/reactive-modules pnpm check:zrth +``` + +For every example it compiles the IR through `index.ts` under the options the example opens with. Then it changes one option at a time to each of its values: `rates`, `shape`, `conflicts`, `marking`, `control` and `layout`. It also tries clock rates with `conflicts: nondet`. A set is kept when the options it changes apply to the net, and two sets that compile alike are tried once (`scripts/option-variants.ts`). Each compiled net is written to a temporary folder and imported by `scripts/construct_net.py`, through `uv run --no-sync python` in the checkout. Importing `net.py` builds every module and the system, so a construction zrth refuses raises there. + +It prints one row per example and option set: `constructs`, `raises`, or `refused` with the compiler's codes. The check passes no code parser, so a net with code strings is refused with `code-not-parsed`. Each raise follows, with the error, the generated lines it passed through and the folder that keeps the files. It exits with 1 when anything raises, and with 2 when `ZRTH_PATH` is unset or zrth does not import from it. + +It is not one of the gates, since CI has no zrth. Run it after a change to the output, and when zrth's `spn` branch moves. At 5bd3bf9 every example the compiler accepts constructs under every set: 38 construct, 9 are refused. Node runs the compiler's TypeScript through `scripts/resolve-ts.ts`, which resolves its imports written without an extension. + +## Extending it + +After any change, run `pnpm lint:tsc`, `pnpm test` and `pnpm build`, and `pnpm check:zrth` when the output changes and a zrth checkout is at hand. A change to the output shows in the tests' goldens and in the examples' Python snapshots in `examples/__snapshots__/`: read the diff, update the snapshots with `pnpm test -u`, and list the change in the commit. Then update what quotes the output: the example pages, the stage text in `playground/compiler-view/stages.ts` and the samples in `playground/compiler-view/stage-sample/`. + +The TypeScript in this folder is formatted as Prettier formats it at a width of 100 columns. Keep new code to that width, and write a short object literal on one line. + +**A compiler option, or a new value for one.** + +1. Add the entry, or the value, to `OPTIONS` in `options.ts`, with `choice` or `positiveNumber`: its values, summary, what it applies to and `notApplicable`. Add a fact to `netFacts` if the rule needs one; the lowerings read the same facts as `plan.facts`. `CompilerOptions`, the panel's rows and the warning follow. +2. Read it where it is lowered: `lower/guard-inputs.ts` for the guard terms under coins, `lower/clocks.ts` under clocks. A lowering that cannot express it adds a refusal and a code. +3. Cover it in `options.test.ts` and the lowering's tests, and add it to the option sets of the differential test if it runs in steps. +4. Update the options table here and the matching section of [mapping.md](mapping.md). In the playground, add a label, a hint and value labels in `playground/options/option-labels.ts`; its test fails until they are there. + +**A lowering.** + +1. Write it in `lower/` as a `Lowering`: `refusals(ir)` lists what it cannot express, by item, and `lower(plan, errors)` builds the graph from the step plan. +2. Pick it in `loweringFor` in `lower/lower.ts`, from the option that asks for it. +3. A module takes its class, its instance and its `source`, the net item its trace names, from a `*ModuleNames` function in `lower/names.ts`. +4. Cover its refusals and its output in tests beside it. + +**A coined identifier.** Add one entry to `PREFIXES` in `lower/names.ts`, with its reading, and a coin function that calls `coin` with that prefix. `names.test.ts` fails until every name of the fixture graphs reads back. + +**A diagnostic.** + +1. Add the code to `DIAGNOSTIC_CODES` in `diagnostics.ts`, with one line of meaning, and a row to the table here. +2. Raise it where it belongs: a malformed document is an issue in `ir/references.ts` with the code in `params.code`; a construct a lowering cannot express goes in that lowering's `refusals`; code outside the linear subset is `refuse(code, message)` in `code/linear-code.ts` or `lower/monolithic/`. +3. `compile` places it at its IR line. Test it beside the file that raises it. + +**An emitted construct**, as `zeroFlow` was added to the SPN graph. + +1. Add the node and its builder to `graph/spn-graph.ts` (or `graph/linear-graph.ts`). +2. Build it in the lowering. +3. Print it in the dialect's expression printer, `expr` in `emit/spn.ts` or `emit/linear.ts`. The exhaustive `switch` fails `pnpm lint:tsc` until the case exists. The case writes each sugar or zrth name it prints through `named(used, name)`, so the file's imports list it. A new sugar name also goes into the dialect's `SUGAR_NAMES`, which orders the import line. +4. The trace needs nothing: the writer traces each line whatever it holds. + + +## Known gaps + +[mapping.md](mapping.md) tracks these with the other open questions. + +- The playground passes no `parseCode`, so the nets with code strings (Boiler, Drones, Bucket) are refused with `code-not-parsed`. A TypeScript parser does not fit the playground's one-file budget. +- Clock rates refuse a capacity (`clocks-capacity`) and an arc weight above one (`clocks-arc-weight`): SPN tests a count against zero only and moves one token at a time. So Queue and the SIR model have no clocked output. Sections (b) and (c) of [mapping.md](mapping.md) hold the questions. +- `pnpm check:zrth` checks that the output constructs, not that it runs. `zrth.simulate` takes no external but `t`, so the clocked output under `conflicts: nondet`, whose picks are externals, cannot run there. No run of the clocked output under `conflicts: sweep` is kept in the repository. diff --git a/pocs/zeroth-playground/compiler/boundary.test.ts b/pocs/zeroth-playground/compiler/boundary.test.ts new file mode 100644 index 00000000..9d299e72 --- /dev/null +++ b/pocs/zeroth-playground/compiler/boundary.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +/** Every TypeScript file of compiler/, keyed by its path from this folder, as text. */ +const SOURCES = import.meta.glob(["./**/*.ts", "!./**/*.d.ts"], { + eager: true, + query: "?raw", + import: "default", +}); + +/** The packages the compiler runs on. */ +const PACKAGES = ["js-yaml", "zod"]; + +/** What the tests and their support add to the compiler's packages. */ +const TEST_PACKAGES = ["vitest"]; + +/** The scripts run under Node, beside the compiler: they may also use Node's built-in modules. */ +const NODE_BUILTIN = /^node:/u; + +type Import = { path: string; specifier: string }; + +/** The module specifiers a file imports from or re-exports. */ +function importsOf(path: string, source: string): Import[] { + return [ + ...source.matchAll(/\bfrom\s+"([^"]+)"|^import\s+"([^"]+)"|\bimport\(\s*"([^"]+)"\s*\)/gmu), + ].map((match) => ({ path, specifier: match[1] ?? match[2] ?? match[3] ?? "" })); +} + +function isTestSupport(path: string): boolean { + return path.endsWith(".test.ts") || path.startsWith("./testing/"); +} + +function isScript(path: string): boolean { + return path.startsWith("./scripts/") && !path.endsWith(".test.ts"); +} + +/** The folders a relative specifier climbs to from the file's folder, `[]` being compiler/ itself. */ +function resolvedFolders({ path, specifier }: Import): string[] | null { + const folders = path.split("/").slice(1, -1); + const segments = specifier.split("/"); + const last = segments.at(-1); + for (const segment of last === "." || last === ".." ? segments : segments.slice(0, -1)) { + if (segment === "..") { + if (folders.pop() === undefined) { + return null; + } + } else if (segment !== ".") { + folders.push(segment); + } + } + return folders; +} + +function isAllowed(entry: Import): boolean { + if (entry.specifier.startsWith(".")) { + return resolvedFolders(entry) !== null; + } + return ( + [...PACKAGES, ...(isTestSupport(entry.path) ? TEST_PACKAGES : [])].includes(entry.specifier) || + (isScript(entry.path) && NODE_BUILTIN.test(entry.specifier)) + ); +} + +const IMPORTS = Object.entries(SOURCES).flatMap(([path, source]) => importsOf(path, source)); + +describe("the compiler folder", () => { + it("imports only its own files, js-yaml and zod; its tests add only vitest, its scripts only Node", () => { + // GIVEN every import of every TypeScript file in compiler/ + const paths = Object.keys(SOURCES); + // WHEN the imports that leave the folder or name another package are picked out + const outside = IMPORTS.filter((entry) => !isAllowed(entry)).map( + ({ path, specifier }) => `${path} imports ${specifier}`, + ); + // THEN the folder was read, and none of its imports is picked out + expect(paths).toContain("./index.ts"); + expect(outside).toEqual([]); + }); + + it("keeps the test support and the scripts out of the code the compiler runs", () => { + // GIVEN the imports of every file that is neither a test, test support nor a script + const running = IMPORTS.filter(({ path }) => !isTestSupport(path) && !isScript(path)); + // WHEN those that reach into testing/ or scripts/ are picked out + const intoTooling = running + .filter((entry) => { + const folder = entry.specifier.startsWith(".") ? resolvedFolders(entry)?.[0] : undefined; + return folder === "testing" || folder === "scripts"; + }) + .map(({ path, specifier }) => `${path} imports ${specifier}`); + // THEN there are none + expect(intoTooling).toEqual([]); + }); + +}); diff --git a/pocs/zeroth-playground/compiler/code/code-strings.test.ts b/pocs/zeroth-playground/compiler/code/code-strings.test.ts new file mode 100644 index 00000000..995e5e85 --- /dev/null +++ b/pocs/zeroth-playground/compiler/code/code-strings.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { boilerIr, bucketIr, dronesIr } from "../testing/coloured-nets.fixtures"; +import { compileNet } from "../testing/compile-net"; +import { parseCode } from "../testing/fixture-parser"; +import { codeStrings } from "./code-strings"; + +import type { CodeParser } from "./code-tree"; + +describe("codeStrings", () => { + it("lists exactly the strings the lowering hands the parser", () => { + // GIVEN the coloured nets, whose dynamics, guards, rates and kernels are code + for (const ir of [bucketIr, boilerIr, dronesIr]) { + const asked = new Set(); + const recording: CodeParser = (code, surface) => { + asked.add(`${surface}: ${code}`); + return parseCode(code, surface); + }; + // WHEN each is compiled with a parser that records what it is asked + compileNet(ir, {}, recording); + // THEN codeStrings lists the same strings, each with its surface + const listed = new Set(codeStrings(ir).map(({ surface, code }) => `${surface}: ${code}`)); + expect(listed).toEqual(asked); + } + }); + + it("names each string's item and field, the dynamics first", () => { + // GIVEN the boiler: dynamics on the tank, and a guard on Alarm + // WHEN its code strings are listed + const listed = codeStrings(boilerIr).map(({ item, field, surface }) => [ + item.kind, + item.name, + field, + surface, + ]); + // THEN each names where it stands and how it is read + expect(listed).toEqual([ + ["dynamics", "Heat", "code", "dynamics"], + ["transition", "Alarm", "guard", "lambda"], + ]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/code/code-strings.ts b/pocs/zeroth-playground/compiler/code/code-strings.ts new file mode 100644 index 00000000..d8fa95c0 --- /dev/null +++ b/pocs/zeroth-playground/compiler/code/code-strings.ts @@ -0,0 +1,37 @@ +import type { NetItem } from "../ir/net-item"; +import type { PetriNetIr } from "../ir/schema"; +import type { CodeSurface } from "./code-tree"; + +/** One code string of a document: where it stands, and the surface a parser reads it as. */ +export type CodeString = { + item: NetItem; + /** The key that holds it: `code` in a dynamics entry, the others in a transition. */ + field: "guard" | "rate" | "kernel" | "code"; + surface: CodeSurface; + code: string; +}; + +/** Every code string of a document: the dynamics first, then each transition's guard, rate and kernel. */ +export function codeStrings(ir: PetriNetIr): CodeString[] { + const dynamics = Object.entries(ir.dynamics ?? {}).map(([name, equation]): CodeString => ({ + item: { kind: "dynamics", name }, + field: "code", + surface: "dynamics", + code: equation.code, + })); + const transitions = Object.entries(ir.transitions).flatMap(([name, transition]) => { + const item: NetItem = { kind: "transition", name }; + return [ + ...(transition.guard === undefined + ? [] + : [{ item, field: "guard", surface: "lambda", code: transition.guard } as const]), + ...(typeof transition.rate === "string" + ? [{ item, field: "rate", surface: "lambda", code: transition.rate } as const] + : []), + ...(transition.kernel === undefined + ? [] + : [{ item, field: "kernel", surface: "kernel", code: transition.kernel } as const]), + ]; + }); + return [...dynamics, ...transitions]; +} diff --git a/pocs/zeroth-playground/compiler/code/code-tree.ts b/pocs/zeroth-playground/compiler/code/code-tree.ts new file mode 100644 index 00000000..8e403342 --- /dev/null +++ b/pocs/zeroth-playground/compiler/code/code-tree.ts @@ -0,0 +1,111 @@ +/** + * The code-parser hook's contract: the trees a parser hands back for the + * IR's code strings. + * + * Input: one code string and the surface it is read as: `lambda` for a + * guard or a rate, `kernel`, or `dynamics`. The string is a bare body of + * TypeScript ending in `return`, with `input` (or `tokens` for dynamics) + * ambient and every constant already inlined. + * + * Output: a `CodeFunction` built from the node kinds below, or `undefined`, + * which refuses the item with `code-not-parsed`. A construct outside these + * kinds is the parser's to refuse, by returning `undefined`. The parser + * need not fold constants: the translation to linear expressions folds + * constant arithmetic itself. + * + * A parser is pure and synchronous, and does not throw. The tree is an + * expression: `let` bindings, conditionals and `map` comprehensions instead + * of statements and loops, and distributions as nodes of their own. It + * carries no ids, spans or types, since nothing reads them. + */ + +export type CodeSurface = "lambda" | "kernel" | "dynamics"; + +/** Reads one code string into a tree, or `undefined` when it cannot. */ +export type CodeParser = (code: string, surface: CodeSurface) => CodeFunction | undefined; + +export type CodeFunction = { + /** The input object first: `input` for a guard, rate or kernel, `tokens` for dynamics. */ + params: { name: string }[]; + body: CodeExpr; +}; + +export type CodeBinaryOp = + | "+" + | "-" + | "*" + | "/" + | "%" + | "**" + | "<" + | "<=" + | ">" + | ">=" + | "==" + | "!=" + | "&&" + | "||"; + +export type CodeUnaryOp = "-" | "+" | "!"; + +/** The `Math` functions a tree can call. */ +export type CodeMathFn = + | "abs" + | "acos" + | "asin" + | "atan" + | "atan2" + | "cbrt" + | "ceil" + | "cos" + | "cosh" + | "exp" + | "floor" + | "hypot" + | "log" + | "log10" + | "log2" + | "max" + | "min" + | "pow" + | "random" + | "round" + | "sign" + | "sin" + | "sinh" + | "sqrt" + | "tan" + | "tanh" + | "trunc"; + +export type CodeDistributionKind = "gaussian" | "uniform" | "lognormal"; + +export type CodeExpr = + | { kind: "numberLit"; value: number } + | { kind: "boolLit"; value: boolean } + | { kind: "stringLit"; value: string } + /** `Math.PI`, `Math.E`, `Infinity` or `NaN`. */ + | { kind: "constant"; name: "PI" | "E" | "Infinity" | "NaN" } + /** A function parameter, a `const` or a `map` callback's parameter. */ + | { kind: "localRef"; name: string } + /** `target.field` or `target["field"]`. */ + | { kind: "fieldAccess"; target: CodeExpr; field: string } + /** `target[index]`. */ + | { kind: "indexAccess"; target: CodeExpr; index: CodeExpr } + /** `target.length`. */ + | { kind: "length"; target: CodeExpr } + | { kind: "unary"; op: CodeUnaryOp; operand: CodeExpr } + | { kind: "binary"; op: CodeBinaryOp; left: CodeExpr; right: CodeExpr } + /** `condition ? thenBranch : elseBranch`. */ + | { kind: "cond"; condition: CodeExpr; thenBranch: CodeExpr; elseBranch: CodeExpr } + /** `const` statements in order, then the `return`. */ + | { kind: "let"; bindings: { name: string; value: CodeExpr }[]; body: CodeExpr } + | { kind: "mathCall"; fn: CodeMathFn; args: CodeExpr[] } + | { kind: "recordLit"; entries: { key: string; value: CodeExpr }[] } + | { kind: "arrayLit"; elements: CodeExpr[] } + /** `target.map((param) => body)`. */ + | { kind: "arrayMap"; target: CodeExpr; param: { name: string }; body: CodeExpr } + /** `Distribution.Gaussian(mean, deviation)` and the like. */ + | { kind: "distribution"; dist: CodeDistributionKind; args: CodeExpr[] } + /** `base.map((param) => body)` over a distribution. */ + | { kind: "distributionMap"; base: CodeExpr; param: { name: string }; body: CodeExpr }; diff --git a/pocs/zeroth-playground/compiler/code/linear-code.test.ts b/pocs/zeroth-playground/compiler/code/linear-code.test.ts new file mode 100644 index 00000000..56ec98fc --- /dev/null +++ b/pocs/zeroth-playground/compiler/code/linear-code.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; + +import { Refusal } from "../diagnostics"; +import { binary, bool, ite, not, num, ref, relu, scale } from "../graph/linear-graph"; +import { + array, + lambdaOf, + lit, + local, + op, + text, + token, + tokenAttribute, +} from "../testing/code-builders"; +import { parseCode } from "../testing/fixture-parser"; +import { type LinearCodeEnv, translateGuard, translateRate } from "./linear-code"; + +import type { DiagnosticCode } from "../diagnostics"; +import type { CodeExpr, CodeFunction } from "./code-tree"; + +/** The tree of a guard or rate, read by the fixture parser. */ +function lambda(code: string): CodeFunction { + return parseCode(code, "lambda"); +} + +/** One bound token of place Hangar: a Real battery, and a state and a mode with values of their own. */ +const env: LinearCodeEnv = { + inputName: "input", + token: (place, index) => + place === "Hangar" && index === 0 + ? { + attribute: (name) => + name === "battery" + ? { expr: ref("Hangar_0_battery"), sort: "number" } + : name === "state" + ? { + expr: ref("Hangar_0_state"), + sort: "string", + codes: ["idle", "flying"], + } + : name === "mode" + ? { expr: ref("Hangar_0_mode"), sort: "string", codes: ["off", "on"] } + : undefined, + } + : undefined, + tokenCount: (place) => (place === "Hangar" ? 1 : undefined), + sample: (kind, args) => + kind === "gaussian" && args[1]?.expr.kind === "num" + ? binary("+", args[0]?.expr ?? num(0), scale(args[1].expr.value, ref("z_0"))) + : undefined, +}; + +/** The code a rate is refused with, or "accepted". */ +function refusalOf(body: CodeExpr): string { + try { + translateRate(lambdaOf(body), env); + } catch (error) { + if (error instanceof Refusal) { + return error.code; + } + throw error; + } + return "accepted"; +} + +describe("the linear translation of a code tree", () => { + it("folds scaling by constants and division by a constant into one factor", () => { + // GIVEN a rate scaled by a constant and divided by one, and a negated rate plus a constant + const scaled = lambda("return 0.5 * (input.Hangar[0].battery / 4);"); + const negated = lambda("return -input.Hangar[0].battery + 2;"); + // WHEN each is translated + const scaledExpr = translateRate(scaled, env); + const negatedExpr = translateRate(negated, env); + // THEN the constants fold into one factor + expect(scaledExpr).toEqual(scale(0.125, ref("Hangar_0_battery"))); + expect(negatedExpr).toEqual(binary("+", scale(-1, ref("Hangar_0_battery")), num(2))); + }); + + it("folds arithmetic on two constants, so a constant factor still scales", () => { + // GIVEN the unfolded trees of (1 + 1) * battery and battery / (6 - 2), as a parser that does not fold gives them + const doubled = lambdaOf( + op(op(lit(1), "+", lit(1)), "*", tokenAttribute("Hangar", 0, "battery")), + ); + const quartered = lambdaOf( + op(tokenAttribute("Hangar", 0, "battery"), "/", op(lit(6), "-", lit(2))), + ); + // WHEN each is translated + const doubledExpr = translateRate(doubled, env); + const quarteredExpr = translateRate(quartered, env); + // THEN each is the attribute scaled by one constant, not a refused product or division + expect(doubledExpr).toEqual(scale(2, ref("Hangar_0_battery"))); + expect(quarteredExpr).toEqual(scale(0.25, ref("Hangar_0_battery"))); + }); + + it("writes max, min and abs with relu", () => { + // GIVEN a rate through Math.max and one through Math.abs + const max = lambda("return Math.max(input.Hangar[0].battery, 50);"); + const abs = lambda("return Math.abs(input.Hangar[0].battery);"); + // WHEN each is translated + const maxExpr = translateRate(max, env); + const absExpr = translateRate(abs, env); + // THEN each is written with relu + expect(maxExpr).toEqual( + binary("+", ref("Hangar_0_battery"), relu(binary("-", num(50), ref("Hangar_0_battery")))), + ); + expect(absExpr).toEqual( + binary("+", relu(ref("Hangar_0_battery")), relu(scale(-1, ref("Hangar_0_battery")))), + ); + }); + + it("compares a string attribute with a literal by its code, and decides a value it never takes", () => { + // GIVEN a guard testing a value the state takes, and one testing a value it never takes + const flying = lambda('return input.Hangar[0].state === "flying";'); + const lost = lambda('return input.Hangar[0].state !== "lost";'); + // WHEN each is translated + const flyingExpr = translateGuard(flying, env); + const lostExpr = translateGuard(lost, env); + // THEN the first compares codes and the second is decided + expect(flyingExpr).toEqual(binary("==", ref("Hangar_0_state"), num(1))); + expect(lostExpr).toEqual(bool(true)); + }); + + it("keeps conditionals, logic, lengths and let bindings", () => { + // GIVEN a guard with let bindings, logic and a length, and a rate with a conditional + const guard = lambda( + "const drone = input.Hangar[0];\nconst low = drone.battery < 20;\nreturn low || (!low && input.Hangar.length >= 1);", + ); + const rate = lambda("return input.Hangar[0].battery > 50 ? 2 : 0.5;"); + // WHEN each is translated + const guardExpr = translateGuard(guard, env); + const rateExpr = translateRate(rate, env); + // THEN the bindings are inlined, the length is the bound count, and the conditional stays + expect(guardExpr).toEqual( + binary( + "|", + binary("<", ref("Hangar_0_battery"), num(20)), + binary( + "&", + not(binary("<", ref("Hangar_0_battery"), num(20))), + binary(">=", num(1), num(1)), + ), + ), + ); + expect(rateExpr).toEqual(ite(binary(">", ref("Hangar_0_battery"), num(50)), num(2), num(0.5))); + }); + + it("draws a Gaussian with a constant spread as mean plus spread times an input", () => { + // GIVEN a rate that maps a Gaussian draw with a constant spread + const rate = lambda( + "return Distribution.Gaussian(input.Hangar[0].battery, 4).map((v) => v + 1);", + ); + // WHEN it is translated + const expr = translateRate(rate, env); + // THEN the draw is the mean plus the spread times a standard input + expect(expr).toEqual( + binary("+", binary("+", ref("Hangar_0_battery"), scale(4, ref("z_0"))), num(1)), + ); + }); + + const battery = tokenAttribute("Hangar", 0, "battery"); + + it.each<[DiagnosticCode, CodeExpr]>([ + ["nonlinear-product", op(battery, "*", battery)], + ["nonlinear-division", op(lit(1), "/", battery)], + ["nonlinear-power", op(battery, "**", lit(2))], + ["nonlinear-math", { kind: "mathCall", fn: "exp", args: [battery] }], + ["math-random", { kind: "mathCall", fn: "random", args: [] }], + ["non-finite-constant", { kind: "constant", name: "Infinity" }], + [ + "distribution-unsupported", + { kind: "distribution", dist: "gaussian", args: [lit(1), battery] }, + ], + ["sort-mismatch", op(battery, "+", { kind: "boolLit", value: true })], + ["string-as-value", text("idle")], + [ + "string-codes-differ", + op(tokenAttribute("Hangar", 0, "state"), "==", tokenAttribute("Hangar", 0, "mode")), + ], + ["token-as-value", token("Hangar", 0)], + ["unbound-local", local("level")], + ["unknown-field", { kind: "fieldAccess", target: lit(1), field: "battery" }], + ["unknown-attribute", tokenAttribute("Hangar", 0, "altitude")], + ["unknown-length", { kind: "length", target: local("input") }], + ["array-in-expression", array(battery)], + ])("refuses with %s what the theories cannot hold", (code, body) => { + // GIVEN a rate outside the linear theories + // WHEN it is translated + const refused = refusalOf(body); + // THEN it is refused with its own code + expect(refused).toBe(code); + }); +}); diff --git a/pocs/zeroth-playground/compiler/code/linear-code.ts b/pocs/zeroth-playground/compiler/code/linear-code.ts new file mode 100644 index 00000000..3daee476 --- /dev/null +++ b/pocs/zeroth-playground/compiler/code/linear-code.ts @@ -0,0 +1,417 @@ +import { + binary, + bool, + ite, + max, + min, + not, + num, + type LinearExpr, + relu, + scale, +} from "../graph/linear-graph"; +import { refuse } from "../diagnostics"; + +import type { DiagnosticCode } from "../diagnostics"; +import type { CodeDistributionKind, CodeExpr, CodeFunction } from "./code-tree"; + +/** + * Translates the linear subset of a code tree to linear expressions: + * sums, scaling by a constant, comparisons, Boolean logic, conditionals, + * `Math.max`, `Math.min` and `Math.abs` through `relu`, string attributes + * as codes, and distributions through inputs the harness draws. Arithmetic + * on two constants is folded, so a parser need not fold it. Everything the + * theories cannot express throws a `Refusal`, which the caller reports + * against its item. + */ + +/** What a token attribute read resolves to. */ +export type AttributeValue = { + expr: LinearExpr; + sort: "number" | "boolean" | "string"; + /** A string attribute's values, whose indices its expression holds. */ + codes?: readonly string[]; +}; + +/** An attribute the theories cannot hold, refused where the code reads it. */ +export type AttributeRefusal = { refused: DiagnosticCode; message: string }; + +export type TokenBinding = { + /** + * The value of one attribute, its refusal when the theories cannot hold + * it, or `undefined` when the colour has no such attribute. + */ + attribute: (name: string) => AttributeValue | AttributeRefusal | undefined; +}; + +export type LinearCodeEnv = { + /** The function's input object: `input` for guards, rates and kernels, `tokens` for dynamics. */ + inputName: string; + /** `input.[index]`, or `undefined` when the place is not bound. */ + token: (place: string, index: number) => TokenBinding | undefined; + /** `input..length`, the arc weight. */ + tokenCount: (place: string) => number | undefined; + /** + * A distribution as a value: an input the harness draws each step, or + * `undefined` when the draw cannot be an input (a token-dependent spread). + */ + sample?: (kind: CodeDistributionKind, args: readonly Translated[]) => LinearExpr | undefined; +}; + +export type Translated = AttributeValue; + +function number(expr: LinearExpr): Translated { + return { expr, sort: "number" }; +} +function boolean(expr: LinearExpr): Translated { + return { expr, sort: "boolean" }; +} + +function asNumber(value: Translated): LinearExpr { + return value.sort === "number" + ? value.expr + : refuse("sort-mismatch", `a ${value.sort} is used where a number is needed`); +} + +function asBoolean(value: Translated): LinearExpr { + return value.sort === "boolean" + ? value.expr + : refuse("sort-mismatch", `a ${value.sort} is used where a boolean is needed`); +} + +function constantOf(expr: LinearExpr): number | undefined { + return expr.kind === "num" ? expr.value : undefined; +} + +/** `factor * operand`, folding a scale of a scale and of a constant. */ +export function scaled(factor: number, operand: LinearExpr): LinearExpr { + if (operand.kind === "num") { + return num(factor * operand.value); + } + if (operand.kind === "scale") { + return scale(factor * operand.factor, operand.operand); + } + return factor === 1 ? operand : scale(factor, operand); +} + +type Scope = { + env: LinearCodeEnv; + /** `const` names in scope, to their values or the tokens they alias. */ + locals: ReadonlyMap; +}; + +function isToken(value: Translated | TokenBinding): value is TokenBinding { + return "attribute" in value; +} + +/** The token an expression denotes, when it denotes one. */ +function resolveToken(node: CodeExpr, scope: Scope): TokenBinding | undefined { + if (node.kind === "localRef") { + const local = scope.locals.get(node.name); + return local !== undefined && isToken(local) ? local : undefined; + } + if ( + node.kind === "indexAccess" && + node.target.kind === "fieldAccess" && + node.target.target.kind === "localRef" && + node.target.target.name === scope.env.inputName && + node.index.kind === "numberLit" + ) { + return scope.env.token(node.target.field, node.index.value); + } + return undefined; +} + +/** Equality on codes, numbers or Bools, as `==` or `!=`. */ +function equality(negate: boolean, left: Translated, right: Translated): Translated { + if (left.sort === "boolean" && right.sort === "boolean") { + const same = binary( + "|", + binary("&", left.expr, right.expr), + binary("&", not(left.expr), not(right.expr)), + ); + return boolean(negate ? not(same) : same); + } + if (left.sort === "string" && right.sort === "string") { + const leftCodes = left.codes ?? []; + const rightCodes = right.codes ?? []; + if ( + leftCodes.length !== rightCodes.length || + leftCodes.some((code, index) => rightCodes[index] !== code) + ) { + return refuse( + "string-codes-differ", + "the two string attributes take different sets of values, so their codes cannot be compared; compare each with a literal instead", + ); + } + } else if (left.sort !== right.sort) { + return refuse("sort-mismatch", `a ${left.sort} is compared with a ${right.sort}`); + } + return boolean(binary(negate ? "!=" : "==", left.expr, right.expr)); +} + +function translate(node: CodeExpr, scope: Scope): Translated { + switch (node.kind) { + case "numberLit": + return number(num(node.value)); + case "boolLit": + return boolean(bool(node.value)); + case "stringLit": + return refuse( + "string-as-value", + "a string literal can only be compared with a string attribute", + ); + case "constant": + switch (node.name) { + case "PI": + return number(num(Math.PI)); + case "E": + return number(num(Math.E)); + default: + return refuse( + "non-finite-constant", + `${node.name} is not a finite number, so it has no value in the linear theories`, + ); + } + case "localRef": { + const local = scope.locals.get(node.name); + if (local === undefined) { + return refuse("unbound-local", `${node.name} is not defined in this code`); + } + if (isToken(local)) { + return refuse( + "token-as-value", + `${node.name} is a whole token; read one of its attributes`, + ); + } + return local; + } + case "fieldAccess": { + const token = resolveToken(node.target, scope); + if (token === undefined) { + return refuse("unknown-field", `${node.field} is read from a value that is not a token`); + } + const value = token.attribute(node.field); + if (value === undefined) { + return refuse("unknown-attribute", `the token has no attribute named ${node.field}`); + } + return "refused" in value ? refuse(value.refused, value.message) : value; + } + case "indexAccess": + return refuse( + "token-as-value", + "a whole token is used as a value; read one of its attributes", + ); + case "length": { + const target = node.target; + if ( + target.kind === "fieldAccess" && + target.target.kind === "localRef" && + target.target.name === scope.env.inputName + ) { + const count = scope.env.tokenCount(target.field); + if (count !== undefined) { + return number(num(count)); + } + } + return refuse("unknown-length", ".length is only known for an input place"); + } + case "unary": { + const operand = translate(node.operand, scope); + switch (node.op) { + case "-": + return number(scaled(-1, asNumber(operand))); + case "+": + return number(asNumber(operand)); + case "!": + return boolean(not(asBoolean(operand))); + } + } + case "binary": { + if (node.op === "==" || node.op === "!=") { + // A string literal takes the code of the attribute it meets. + const negate = node.op === "!="; + const literal = + node.left.kind === "stringLit" + ? node.left + : node.right.kind === "stringLit" + ? node.right + : undefined; + if (literal !== undefined) { + const other = literal === node.left ? node.right : node.left; + const attribute = translate(other, scope); + if (attribute.sort !== "string") { + return refuse("sort-mismatch", `a string is compared with a ${attribute.sort}`); + } + const code = attribute.codes?.indexOf(literal.value) ?? -1; + if (code === -1) { + // A value the attribute never takes: the comparison is decided. + return boolean(bool(negate)); + } + return boolean(binary(negate ? "!=" : "==", attribute.expr, num(code))); + } + return equality(negate, translate(node.left, scope), translate(node.right, scope)); + } + const left = translate(node.left, scope); + const right = translate(node.right, scope); + switch (node.op) { + case "+": + case "-": { + const leftValue = asNumber(left); + const rightValue = asNumber(right); + const leftConstant = constantOf(leftValue); + const rightConstant = constantOf(rightValue); + if (leftConstant !== undefined && rightConstant !== undefined) { + return number( + num(node.op === "+" ? leftConstant + rightConstant : leftConstant - rightConstant), + ); + } + return number(binary(node.op, leftValue, rightValue)); + } + case "*": { + const leftValue = asNumber(left); + const rightValue = asNumber(right); + const leftConstant = constantOf(leftValue); + const rightConstant = constantOf(rightValue); + if (leftConstant !== undefined) { + return number(scaled(leftConstant, rightValue)); + } + if (rightConstant !== undefined) { + return number(scaled(rightConstant, leftValue)); + } + return refuse( + "nonlinear-product", + "two values the tokens decide are multiplied; the linear theories add, subtract, scale by a constant and compare", + ); + } + case "/": { + const divisor = constantOf(asNumber(right)); + if (divisor === undefined || divisor === 0) { + return refuse( + "nonlinear-division", + "a value is divided by one the tokens decide; the linear theories only divide by a non-zero constant", + ); + } + return number(scaled(1 / divisor, asNumber(left))); + } + case "%": + case "**": + return refuse( + "nonlinear-power", + `a value is raised to a power (${node.op}); the linear theories add, subtract, scale by a constant and compare`, + ); + case "<": + case "<=": + case ">": + case ">=": + return boolean(binary(node.op, asNumber(left), asNumber(right))); + case "&&": + return boolean(binary("&", asBoolean(left), asBoolean(right))); + case "||": + return boolean(binary("|", asBoolean(left), asBoolean(right))); + } + } + case "cond": { + const condition = asBoolean(translate(node.condition, scope)); + const thenBranch = translate(node.thenBranch, scope); + const elseBranch = translate(node.elseBranch, scope); + if (thenBranch.sort !== elseBranch.sort) { + return refuse("sort-mismatch", "the two branches of the conditional have different sorts"); + } + return { + expr: ite(condition, thenBranch.expr, elseBranch.expr), + sort: thenBranch.sort, + ...(thenBranch.codes === undefined ? {} : { codes: thenBranch.codes }), + }; + } + case "let": { + const locals = new Map(scope.locals); + for (const binding of node.bindings) { + const token = resolveToken(binding.value, { ...scope, locals }); + locals.set(binding.name, token ?? translate(binding.value, { ...scope, locals })); + } + return translate(node.body, { ...scope, locals }); + } + case "mathCall": { + const args = node.args.map((arg) => asNumber(translate(arg, scope))); + switch (node.fn) { + case "max": + return number(args.reduce((left, right) => max(left, right))); + case "min": + return number(args.reduce((left, right) => min(left, right))); + case "abs": { + const [operand] = args; + return operand === undefined + ? refuse("nonlinear-math", "Math.abs takes one argument") + : number(binary("+", relu(operand), relu(scaled(-1, operand)))); + } + case "random": + return refuse( + "math-random", + "Math.random cannot run in a module; use a Distribution, which becomes an input the harness draws", + ); + default: + return refuse( + "nonlinear-math", + `Math.${node.fn} of a value the tokens decide has no linear form; the linear theories add, subtract, scale by a constant and compare`, + ); + } + } + case "distribution": { + const args = node.args.map((arg) => translate(arg, scope)); + const drawn = scope.env.sample?.(node.dist, args); + return drawn === undefined + ? refuse( + "distribution-unsupported", + `a ${node.dist} draw cannot be an input here; the harness draws Uniform and Gaussian with constant spreads`, + ) + : number(drawn); + } + case "distributionMap": { + const base = translate(node.base, scope); + const locals = new Map(scope.locals); + locals.set(node.param.name, base); + return translate(node.body, { ...scope, locals }); + } + case "arrayMap": + case "arrayLit": + case "recordLit": + return refuse( + "array-in-expression", + "an array or record is not a value the theories hold; read one element or field", + ); + } +} + +/** The function's body as a reactive expression, with its result sort. */ +function translateCodeBody(fn: CodeFunction, env: LinearCodeEnv): Translated { + return translate(fn.body, { env, locals: new Map() }); +} + +/** A guard: the body must be a Bool. */ +export function translateGuard(fn: CodeFunction, env: LinearCodeEnv): LinearExpr { + return asBoolean(translateCodeBody(fn, env)); +} + +/** A rate: the body must be a number. */ +export function translateRate(fn: CodeFunction, env: LinearCodeEnv): LinearExpr { + return asNumber(translateCodeBody(fn, env)); +} + +/** An expression of the body, for a kernel's attribute write or a derivative. */ +export function translateValue( + node: CodeExpr, + env: LinearCodeEnv, + locals: ReadonlyMap = new Map(), +): Translated { + return translate(node, { env, locals }); +} + +/** Resolves a token expression under the caller's locals, for kernels reading `input.P[i]`. */ +export function resolveTokenExpr( + node: CodeExpr, + env: LinearCodeEnv, + locals: ReadonlyMap = new Map(), +): TokenBinding | undefined { + return resolveToken(node, { env, locals }); +} diff --git a/pocs/zeroth-playground/compiler/compile.test.ts b/pocs/zeroth-playground/compiler/compile.test.ts new file mode 100644 index 00000000..91b5efd4 --- /dev/null +++ b/pocs/zeroth-playground/compiler/compile.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { compile } from "./compile"; +import { resolveOptions } from "./options"; +import { birthDeathIr } from "./testing/birth-death.fixtures"; +import { bucketIr, bucketPython, dronesIr, dronesOptions } from "./testing/coloured-nets.fixtures"; +import { yaml } from "./testing/compile-net"; +import { parseCode } from "./testing/fixture-parser"; +import { cycle } from "./testing/small-nets.fixtures"; + +import type { PetriNetIr } from "./ir/schema"; + +/** The birth-death net with a controllable Death. */ +const controlledBirthDeath: PetriNetIr = { + ...birthDeathIr, + transitions: { + ...birthDeathIr.transitions, + Death: { ...birthDeathIr.transitions.Death, controllable: true }, + }, +}; + +/** A stochastic net whose one place holds coloured tokens, with no code. */ +const pool: PetriNetIr = { + name: "pool", + kind: "stochastic", + colours: { Ball: { size: "real" } }, + places: { Pool: { colour: "Ball" } }, + transitions: { Drain: { inputs: { Pool: null }, rate: 1 } }, +}; + +/** The 1-based line of the first line of the text that reads exactly so. */ +function lineReading(text: string, line: string): number { + return text.split("\n").indexOf(line) + 1; +} + +describe("compile", () => { + it("opens the module on its imports and steps it in next", () => { + // GIVEN the cycle + // WHEN it is compiled + const { files } = compile(yaml(cycle)); + // THEN the one file starts on its zrth import, and its step method is next + expect(files.map((file) => file.path)).toEqual(["net.py"]); + expect(files[0]?.text.startsWith("from zrth import ")).toBe(true); + expect(files[0]?.text).toContain(" def next(self, A, B):"); + }); + + it("reports the options in force: those asked for that apply, the defaults for the rest", () => { + // GIVEN the plain cycle, asked for the modular shape, a step and one file per module + // WHEN it is compiled + const { options } = compile(yaml(cycle), { + options: { shape: "modular", dt: 0.5, layout: "per-module" }, + }); + // THEN the step, which a plain net does not have, is back at its default + expect(options).toEqual({ ...resolveOptions(), shape: "modular", layout: "per-module" }); + }); + + it("writes one file under the monolithic shape whatever the layout says", () => { + // GIVEN the monolithic cycle, asked for one module per file + // WHEN it is compiled + const { files, options, warnings } = compile(yaml(cycle), { + options: { layout: "per-module" }, + }); + // THEN one module needs one file, and the layout is dropped with a warning + expect(files.map((file) => file.path)).toEqual(["net.py"]); + expect(options.layout).toBe("single"); + expect(warnings.map((warning) => warning.code)).toEqual(["option-not-applicable"]); + }); + + it("reads code strings with the parser it is given", () => { + // GIVEN the bucket, whose rate reads each ball + // WHEN it is compiled with a parser, and without one + const parsed = compile(yaml(bucketIr), { parseCode }); + const unparsed = compile(yaml(bucketIr)); + // THEN the parser's trees compile, and without them the rate is refused + expect(parsed.files[0]?.text).toBe(bucketPython); + expect(unparsed.errors.map((error) => error.code)).toEqual(["code-not-parsed"]); + }); +}); + +describe("compile diagnostics", () => { + it("refuses a document that carries code, one diagnostic per code surface at the item's IR line", () => { + // GIVEN the drones, with dynamics and kernels as code + const text = yaml(dronesIr); + // WHEN they are compiled without a parser + const { ir, files, errors } = compile(text, { options: dronesOptions }); + // THEN the document stands, and each code string is refused at its item's first line + expect(ir).not.toBeNull(); + expect(files).toEqual([]); + expect(errors.map((error) => [error.code, error.item.name, error.line])).toEqual([ + ["code-not-parsed", "Airborne", lineReading(text, " Airborne:")], + ["code-not-parsed", "Launch", lineReading(text, " Launch:")], + ["code-not-parsed", "Launch", lineReading(text, " Launch:")], + ["code-not-parsed", "Land", lineReading(text, " Land:")], + ["code-not-parsed", "Land", lineReading(text, " Land:")], + ]); + }); + + it("drops an option that does not apply with a warning on the net's name, and compiles without it", () => { + // GIVEN the birth-death net with a controllable Death + const text = yaml(controlledBirthDeath); + // WHEN it is compiled under clocks with control open + const { files, warnings } = compile(text, { options: { rates: "clock", control: "open" } }); + // THEN it compiles, and the open control is dropped with its reason at the name's line + expect(files).toHaveLength(1); + expect( + warnings.map((warning) => [warning.code, warning.item.kind, warning.line, warning.message]), + ).toEqual([ + [ + "option-not-applicable", + "net", + lineReading(text, "name: birth_death"), + "control: open is ignored. Clock rates take no external choice.", + ], + ]); + }); + + it("compiles a coloured net under coins when clock rates are asked for, warning that they do not apply", () => { + // GIVEN a stochastic net whose one place holds coloured tokens + const text = yaml(pool); + // WHEN it is compiled under clock rates + const compiled = compile(text, { options: { rates: "clock" } }); + // THEN the rates are dropped with their reason, and the net compiles as under coins + expect(compiled.errors).toEqual([]); + expect(compiled.warnings.map((warning) => warning.message)).toEqual([ + "rates: clock is ignored. A coloured net, or one with dynamics, compiles under coins only.", + ]); + expect(compiled.files.map((file) => file.text)).toEqual( + compile(text).files.map((file) => file.text), + ); + }); + + it("warns once for each step option clock rates leave out, and compiles as without them", () => { + // GIVEN the birth-death net with a controllable Death + const text = yaml(controlledBirthDeath); + // WHEN it is compiled under clocks with every step option set, and under clocks alone + const busy = compile(text, { + options: { rates: "clock", shape: "monolithic", marking: "int", control: "open", dt: 0.25 }, + }); + const clocks = compile(text, { options: { rates: "clock" } }); + // THEN the options off their default are each warned about, and the Python is the same + expect(busy.warnings.map((warning) => warning.message.split(" is ignored.")[0])).toEqual([ + "marking: int", + "control: open", + "dt: 0.25", + ]); + expect(busy.files.map((file) => file.text)).toEqual(clocks.files.map((file) => file.text)); + }); + + it("returns the parse errors and no document for a text that is not an IR", () => { + // GIVEN a text that is not YAML + // WHEN it is compiled with the modular shape + const compiled = compile("places: [\n", { options: { shape: "modular" } }); + // THEN there is no document, no trace, and the options are the ones asked for + expect(compiled.ir).toBeNull(); + expect(compiled.irTrace).toEqual([]); + expect(compiled.errors[0]?.code).toBe("yaml-syntax"); + expect(compiled.options.shape).toBe("modular"); + }); +}); diff --git a/pocs/zeroth-playground/compiler/compile.ts b/pocs/zeroth-playground/compiler/compile.ts new file mode 100644 index 00000000..b7bfcaab --- /dev/null +++ b/pocs/zeroth-playground/compiler/compile.ts @@ -0,0 +1,85 @@ +import { emitPython } from "./emit/emit"; +import { parsePetriNetIr } from "./ir/parse"; +import { lowerPetriNetIr } from "./lower/lower"; +import { droppedOptions, optionsForNet, resolveOptions } from "./options"; +import { firstLineOf } from "./trace/provenance"; +import { traceIr } from "./trace/trace-ir"; + +import type { Diagnostic } from "./diagnostics"; +import type { CompiledFile } from "./emit/python"; +import type { PetriNetIr } from "./ir/schema"; +import type { ModuleGraph } from "./graph/module-graph"; +import type { CodeParser } from "./code/code-tree"; +import type { CompilerOptions, ResolvedOptions } from "./options"; +import type { Trace } from "./trace/provenance"; + +export type CompileSettings = { + /** + * The options asked for; the document carries none. One that does not + * apply to the net is dropped with an `option-not-applicable` warning. + */ + options?: CompilerOptions; + /** + * Reads the document's code strings back into code trees. Without one, a + * net with code strings is refused with `code-not-parsed`. + */ + parseCode?: CodeParser; +}; + +export type Compilation = { + /** The document the text parses to, or `null` when it is not an IR. */ + ir: PetriNetIr | null; + /** The options in force: those asked for that apply to the net, the defaults for the rest. */ + options: ResolvedOptions; + /** The trace over the IR text; empty without a document. */ + irTrace: Trace; + /** `null` when parsing or lowering refuses. */ + graph: ModuleGraph | null; + /** `net.py` first; empty when refused. */ + files: CompiledFile[]; + /** Each with its IR line when one is found. */ + errors: Diagnostic[]; + warnings: Diagnostic[]; +}; + +/** Each diagnostic at the first IR line of its item, when the trace has one. */ +function located(diagnostics: readonly Diagnostic[], irTrace: Trace): Diagnostic[] { + return diagnostics.map((diagnostic) => { + const line = firstLineOf(irTrace, diagnostic.item); + return line === undefined ? diagnostic : { ...diagnostic, line }; + }); +} + +/** The whole pipeline over one IR text: parse, keep the options that apply, trace, lower, emit. */ +export function compile(text: string, { options, parseCode }: CompileSettings = {}): Compilation { + const parsed = parsePetriNetIr(text); + if (!parsed.ok) { + return { + ir: null, + options: resolveOptions(options), + irTrace: [], + graph: null, + files: [], + errors: parsed.errors, + warnings: [], + }; + } + const { ir } = parsed; + const inForce = resolveOptions(optionsForNet(options, ir)); + const irTrace = traceIr(ir, text, inForce); + const read = { + ir, + options: inForce, + irTrace, + warnings: located(droppedOptions(options, ir), irTrace), + }; + const lowered = lowerPetriNetIr(ir, inForce, parseCode); + return lowered.ok + ? { + ...read, + graph: lowered.graph, + files: emitPython(lowered.graph, inForce.layout, ir), + errors: [], + } + : { ...read, graph: null, files: [], errors: located(lowered.errors, irTrace) }; +} diff --git a/pocs/zeroth-playground/compiler/diagnostics.ts b/pocs/zeroth-playground/compiler/diagnostics.ts new file mode 100644 index 00000000..d6670228 --- /dev/null +++ b/pocs/zeroth-playground/compiler/diagnostics.ts @@ -0,0 +1,106 @@ +import type { NetItem } from "./ir/net-item"; + +/** Every diagnostic code, with one line of what it means. */ +export const DIAGNOSTIC_CODES = { + // The text is not a document. + "yaml-syntax": "The text is not YAML.", + schema: "The document does not fit the IR's shape.", + "unknown-place": "An arc or the marking names a place that is not declared.", + "unknown-colour": "A place or a dynamics entry names a colour that is not declared.", + "unknown-dynamics": "A place names dynamics that are not declared.", + "reserved-name": "An IR name, or the net's class name, is one the generated Python uses.", + "marking-invalid": + "A plain place starts with something other than a whole count, or a coloured one without a token list.", + "marking-over-capacity": "A place starts with more tokens than its capacity.", + "kind-mismatch": "The net's kind disagrees with its rates.", + + // An option is dropped. + "option-not-applicable": "An option asked for does not apply to the net and is ignored.", + + // A lowering cannot express the net. + "modular-coloured-not-lowered": + "The modular shape is not lowered for coloured places or dynamics.", + "clocks-capacity": "Clock rates cannot test a capacity.", + "clocks-plain-transition": "Clock rates need a rate on every transition.", + "clocks-rate-code": "Clock rates need a constant rate.", + "clocks-rate-not-positive": "Clock rates need a positive rate.", + "clocks-guard": "Clock rates cannot test a guard.", + "clocks-kernel": "Clock rates move plain tokens and run no kernel.", + "clocks-arc-weight": "Clock rates move one token per arc.", + "code-not-parsed": + "A code string could not be read into a tree; no parser was given, or it returned none.", + "marking-exceeds-slots": "A coloured place starts with more tokens than it has slots.", + "kernel-missing": "A transition produces coloured tokens without a kernel.", + "binding-explosion": "A transition has too many token combinations to try.", + + // Code outside the linear theories. + "nonlinear-product": "Two values the tokens decide are multiplied.", + "nonlinear-division": "A value is divided by one that is not a non-zero constant.", + "nonlinear-power": "A value is raised to a power or taken modulo another.", + "nonlinear-math": "A Math function has no linear form.", + "math-random": "Math.random cannot run in a module.", + "non-finite-constant": "Infinity or NaN has no value in the linear theories.", + "distribution-unsupported": "A draw cannot become an input the harness draws.", + "sort-mismatch": "A number, boolean or string is used where another sort is needed.", + "string-as-value": "A string literal is used other than in a comparison with a string attribute.", + "string-codes-differ": "Two string attributes with different values are compared.", + "string-code-unknown": "A kernel writes a string the attribute does not take.", + "token-as-value": "A whole token is used as a value.", + "unbound-local": "The code reads a name it does not define.", + "unknown-field": "A field is read from a value that is not a token.", + "unknown-attribute": "The token's colour has no such attribute.", + "unknown-length": "A length is read from something other than an input place.", + "attribute-not-lowerable": + "The code reads an attribute the theories cannot hold: a uuid or an open string.", + "array-in-expression": "An array or a record is used as a value.", + "kernel-output-shape": "A kernel's result is not a record of token lists keyed by output place.", + "kernel-output-missing": "A kernel writes nothing into a coloured output place.", + "kernel-output-count": "A kernel writes a different number of tokens than the arc carries.", + "kernel-attribute-missing": "A produced token lacks an attribute of its colour.", + "dynamics-shape": "Dynamics are not a map of the tokens to a record of derivatives.", +} as const; + +export type DiagnosticCode = keyof typeof DIAGNOSTIC_CODES; + +/** + * Why an IR, or one of its items, cannot become a module, or what the + * compile left out. `compile` adds the IR line the item starts on. + */ +export type Diagnostic = { + code: DiagnosticCode; + message: string; + item: NetItem; + /** 1-based, in the IR text. */ + line?: number; +}; + +/** Code or a construct the lowering cannot express; the caller reports it on its item. */ +export class Refusal extends Error { + readonly code: DiagnosticCode; + + constructor(code: DiagnosticCode, message: string) { + super(message); + this.code = code; + } +} + +/** Throws a `Refusal`, where an expression is expected. */ +export function refuse(code: DiagnosticCode, message: string): never { + throw new Refusal(code, message); +} + +/** Runs `compute`, saying which code a refusal comes from: "In the guard, ...". */ +export function within(surface: "guard" | "rate" | "kernel" | "dynamics", compute: () => T): T { + try { + return compute(); + } catch (error) { + if (error instanceof Refusal) { + throw new Refusal(error.code, `In the ${surface}, ${error.message}`); + } + throw error; + } +} + +export function isDiagnosticCode(code: unknown): code is DiagnosticCode { + return typeof code === "string" && Object.hasOwn(DIAGNOSTIC_CODES, code); +} diff --git a/pocs/zeroth-playground/compiler/emit/describe.test.ts b/pocs/zeroth-playground/compiler/emit/describe.test.ts new file mode 100644 index 00000000..90b345a2 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/describe.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; + +import { birthDeathIr, birthDeathOptions } from "../testing/birth-death.fixtures"; +import { compileNet } from "../testing/compile-net"; +import { forkClockedIr, forkClockedOptions, forkIr } from "../testing/fork.fixtures"; +import { lineContaining } from "../testing/line-containing"; +import { servedQueue } from "../testing/small-nets.fixtures"; +import { provenanceAt } from "../trace/provenance"; + +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** The net's main file with its trace, or a thrown error naming the net. */ +function compiled(ir: PetriNetIr, options: CompilerOptions) { + const main = compileNet(ir, options).files[0]; + if (main === undefined) { + throw new Error(`expected ${ir.name} to compile`); + } + return main; +} + +describe("the provenance written beside the Python", () => { + const { text, trace } = compiled(servedQueue, { shape: "modular", dt: 0.5 }); + + it("describes variables, modules, methods, statements and the system", () => { + // GIVEN the modular queue's Python and its trace + // WHEN declarations, a class, a method, a statement, the system and an instance are looked up + const fire = provenanceAt(trace, lineContaining(text, "fire_Serve = Var(")); + const draw = provenanceAt(trace, lineContaining(text, "u_Arrive = Var(")); + const place = provenanceAt(trace, lineContaining(text, "Waiting = Var(")); + const module = provenanceAt(trace, lineContaining(text, "class Place_Waiting(Module):")); + const method = provenanceAt(trace, lineContaining(text, " def next(self, Waiting")); + const statement = provenanceAt(trace, lineContaining(text, " Waiting = ite(")); + const system = provenanceAt(trace, lineContaining(text, "net = compose(")); + const instance = provenanceAt(trace, lineContaining(text, "place_Waiting = Place_Waiting(")); + // THEN each says what it is and which item it comes from + expect(fire).toMatchObject({ + what: "Serve fires this step", + source: { kind: "transition", name: "Serve" }, + }); + expect(draw).toEqual({ + what: "Uniform draw for Arrive, each step", + why: "An input the harness writes; the transition fires when the draw is at least e^(-rate·dt).", + source: { kind: "transition", name: "Arrive" }, + }); + expect(place).toMatchObject({ + what: "The tokens in Waiting", + ir: "places.Waiting", + source: { kind: "place", name: "Waiting" }, + }); + expect(module).toMatchObject({ + what: "The module of place Waiting", + source: { kind: "place", name: "Waiting" }, + }); + expect(method?.what).toBe("One step of the net: the statements, then the next values"); + expect(statement).toMatchObject({ + what: "Sets the tokens in Waiting", + source: { kind: "place", name: "Waiting" }, + }); + expect(system).toMatchObject({ + what: "The system", + source: { kind: "net", name: "queue" }, + }); + expect(instance).toMatchObject({ + what: "An instance of Place_Waiting in the LRA theory", + }); + }); + + it("drops a lone read's comma", () => { + // GIVEN the modular queue's Python and its trace + // WHEN the instance of Served, which reads one variable, is looked up + const instance = provenanceAt(trace, lineContaining(text, "place_Served = Place_Served(")); + // THEN its reads are listed without a trailing comma + expect(instance?.why).toBe("Drives Served and reads fire_Serve."); + }); + + it("reads the monolithic net as the system and keeps a name's case", () => { + // GIVEN the queue compiled as one module + // WHEN it is emitted with its trace + const { text: python, trace: singleTrace } = compiled(servedQueue, { + shape: "monolithic", + dt: 0.5, + }); + // THEN the one instance is the system, and a local keeps its name's case + expect(provenanceAt(singleTrace, lineContaining(python, "net = "))).toMatchObject({ + what: "The system", + why: "The one module, driving every place.", + }); + expect(provenanceAt(singleTrace, lineContaining(python, " fire_Serve = "))?.what).toBe( + "Sets fire_Serve: Serve fires this step", + ); + }); + + it("names a draw module after its transition under marking int", () => { + // GIVEN the modular queue with Int places, so each rated transition has a draw module + // WHEN it is emitted with its trace + const { text: python, trace: intTrace } = compiled(servedQueue, { + shape: "modular", + marking: "int", + }); + // THEN the draw module's class and instance stand for the transition + expect( + provenanceAt(intTrace, lineContaining(python, "class Draw_Arrive(Module):")), + ).toMatchObject({ + what: "The draw module of Arrive", + ir: "transitions.Arrive", + source: { kind: "transition", name: "Arrive" }, + }); + expect( + provenanceAt(intTrace, lineContaining(python, "draw_Arrive = Draw_Arrive(")), + ).toMatchObject({ + what: "An instance of Draw_Arrive in the LRA theory", + why: "Drives hit_Arrive and reads u_Arrive.", + source: { kind: "transition", name: "Arrive" }, + }); + expect( + provenanceAt(intTrace, lineContaining(python, "class Transition_Arrive(Module):"))?.what, + ).toBe("The module of transition Arrive"); + }); +}); + +describe("provenance under conflicts nondet", () => { + const pick = { + what: "The environment lets TakeLeft fire this step", + why: "An input nothing drives: any resolution of the conflict is a run, and a proof ranges over all of them.", + source: { kind: "transition", name: "TakeLeft" }, + }; + + it("describes the pick in the coin module", () => { + // GIVEN the fork with conflicts left open + // WHEN it is emitted with its trace + const { text, trace } = compiled(forkIr, { conflicts: "nondet" }); + // THEN the pick and the firing say what they are + expect(provenanceAt(trace, lineContaining(text, "pick_TakeLeft = Var(BOOL)"))).toEqual(pick); + expect(provenanceAt(trace, lineContaining(text, " fire_TakeLeft = "))?.what).toBe( + "Sets fire_TakeLeft: TakeLeft fires this step", + ); + }); + + it("describes the pick's Bool declaration and instance under clock rates", () => { + // GIVEN the clocked fork + // WHEN it is emitted with its trace + const { text, trace } = compiled(forkClockedIr, forkClockedOptions); + // THEN the pick is described with its clocked comment, and the transition's instance reads it + expect(provenanceAt(trace, lineContaining(text, "pick_TakeLeft = Var(Bool([1, 1]))"))).toEqual({ + ...pick, + why: `${pick.why} the environment lets TakeLeft fire when its clock expires`, + }); + expect( + provenanceAt(trace, lineContaining(text, "transition_TakeLeft = Transition_TakeLeft("))?.why, + ).toBe("Drives clk_TakeLeft, ev_TakeLeft and reads Pool, pick_TakeLeft, t."); + }); +}); + +describe("the provenance written beside the Python under clock rates", () => { + const { text, trace } = compiled(birthDeathIr, birthDeathOptions); + + it("describes the time reference, the clocks, the events and their locals", () => { + // GIVEN the clocked birth-death Python and its trace + // WHEN the time reference, a clock, an event, a counter and two locals are looked up + const time = provenanceAt(trace, lineContaining(text, "t = Var(Clock())")); + const clock = provenanceAt(trace, lineContaining(text, "clk_Birth = Var(")); + const event = provenanceAt(trace, lineContaining(text, "ev_Death = Var(")); + const counter = provenanceAt(trace, lineContaining(text, "Population = Var(Nat())")); + const fires = provenanceAt(trace, lineContaining(text, " fires_Birth = ")); + const fired = provenanceAt(trace, lineContaining(text, " fired_Death = ")); + // THEN each says what it is + expect(time).toEqual({ + what: "The time reference", + why: "External and driven by nothing: a clock's flow is a rate against d(t), so a module that reads it awaits t.", + }); + expect(clock).toMatchObject({ + what: "Time left until Birth fires", + source: { kind: "transition", name: "Birth" }, + }); + expect(event).toMatchObject({ + what: "Toggles when Death fires", + source: { kind: "transition", name: "Death" }, + }); + expect(counter).toMatchObject({ what: "The tokens in Population" }); + expect(fires?.what).toBe("Sets fires_Birth: Birth's clock ran out and its arcs allow it"); + expect(fired).toMatchObject({ + what: "Sets fired_Death: Death fired this step", + source: { kind: "transition", name: "Death" }, + }); + }); + + it("describes the methods, their returns, the instances and the hidden clocks", () => { + // GIVEN the clocked birth-death Python and its trace + // WHEN the methods, their returns, a class, an instance, the hide set + // and the system are looked up + const next = provenanceAt(trace, lineContaining(text, " def next(self, clk_Birth")); + const flow = provenanceAt(trace, lineContaining(text, " def flow(self, clk_Birth")); + const tangents = provenanceAt(trace, lineContaining(text, " return ite(clk_Birth >= 0")); + const initial = provenanceAt(trace, lineContaining(text, " return exp(2.0), False")); + const module = provenanceAt(trace, lineContaining(text, "class Transition_Birth(Module):")); + const instance = provenanceAt( + trace, + lineContaining(text, "transition_Birth = Transition_Birth("), + ); + const hidden = provenanceAt(trace, lineContaining(text, " hide={clk_Birth, clk_Death},")); + const system = provenanceAt(trace, lineContaining(text, "net = compose(")); + // THEN each says what it is and why it is there + expect(next).toMatchObject({ + what: "A firing: the statements, then the next values", + }); + expect(flow).toEqual({ + what: "The tangents between firings, one per driven variable", + why: "A clock counts down against t while its transition is enabled; an event flows 0 and stays still.", + }); + expect(tangents?.what).toBe("The tangents, one per driven variable, 0 where it does not move"); + expect(initial?.what).toBe("The initial values, one per driven variable"); + expect(module).toMatchObject({ + what: "The module of transition Birth", + why: "Birth: nothing -> Population, at rate 2", + }); + expect(instance?.why).toBe("Drives clk_Birth, ev_Birth and reads t."); + expect(hidden).toMatchObject({ + what: "The clocks kept private", + source: { kind: "net", name: "birth_death" }, + }); + expect(system).toMatchObject({ + what: "The system", + why: "Every module composed: a variable one module drives is awaited by the others, in an order the awaits allow.", + }); + }); +}); diff --git a/pocs/zeroth-playground/compiler/emit/describe.ts b/pocs/zeroth-playground/compiler/emit/describe.ts new file mode 100644 index 00000000..eb1e7c00 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/describe.ts @@ -0,0 +1,202 @@ +import { describeName } from "../lower/names"; +import { placeWhy } from "../trace/describe-item"; + +import type { ModuleGraph } from "../graph/module-graph"; +import type { NetItem } from "../ir/net-item"; +import type { PetriNetIr } from "../ir/schema"; +import type { Provenance } from "../trace/provenance"; + +/** + * What each emitted line of Python is, in words: the provenance an emitter + * writes beside it. A coined name reads back through `describeName`, a + * place through the IR. + */ + +export type MethodName = "init" | "next" | "flow"; + +/** The parts of a module its class and instance are described by. */ +type DescribedModule = { + className: string; + docstring: string; + ctrl: readonly string[]; + extl: readonly string[]; + source: NetItem; + draw?: true; +}; + +export const IMPORTS: Provenance = { + what: "What the module needs from zrth, and the modules of the other files", + why: "Only the names the bodies use are imported.", +}; + +/** `INT = Int([1, 1])`. */ +export function sortConstantProvenance(constructor: string): Provenance { + return { + what: `The ${constructor} sort of a single value`, + why: "Every variable is a 1×1 tensor of this sort.", + }; +} + +function variableProvenance( + name: string, + ir: PetriNetIr, + comment: string | undefined, + role: "declaration" | "statement", +): Provenance { + const described = describeName(name); + if (described !== null) { + // The emitter's trailing comment repeats the what for some names, in + // its own case; it joins the why only when it says something else. + const notes = [...new Set([described.why, comment])].filter( + (note): note is string => + note !== undefined && note !== "" && note.toLowerCase() !== described.what.toLowerCase(), + ); + return { + what: role === "statement" ? `Sets ${name}: ${described.what}` : described.what, + ...(notes.length === 0 ? {} : { why: notes.join(" ") }), + ...(described.source === undefined ? {} : { source: described.source }), + }; + } + if (name in ir.places) { + return { + what: role === "statement" ? `Sets the tokens in ${name}` : `The tokens in ${name}`, + why: comment ?? placeWhy(ir, name), + ir: `places.${name}`, + source: { kind: "place", name }, + }; + } + return { + what: role === "statement" ? `A local of the step, ${name}` : name, + why: comment, + }; +} + +/** `Pool = Var(INT)`: a variable of the system. */ +export function declarationProvenance( + variable: { name: string; role: string; comment?: string }, + ir: PetriNetIr, +): Provenance { + const provenance = variableProvenance(variable.name, ir, variable.comment, "declaration"); + return variable.role === "input" + ? { ...provenance, why: provenance.why ?? "An input the harness writes each step." } + : provenance; +} + +/** `fire_Go = A >= 1`: a statement of a step. */ +export function statementProvenance( + statement: { target: string; comment?: string }, + ir: PetriNetIr, +): Provenance { + return variableProvenance(statement.target, ir, statement.comment, "statement"); +} + +/** A comment line of a step says what follows itself. */ +export function commentProvenance(text: string): Provenance { + return { what: text }; +} + +function moduleWhat({ source, draw }: DescribedModule): string { + switch (source.kind) { + case "transition": + return draw === true + ? `The draw module of ${source.name}` + : `The module of transition ${source.name}`; + case "place": + return `The module of place ${source.name}`; + default: + return "The module of the whole net"; + } +} + +/** A module's class, from its header to its last method. */ +export function moduleProvenance(module: DescribedModule): Provenance { + const { source } = module; + return { + what: moduleWhat(module), + why: module.docstring, + ...(source.kind === "net" ? {} : { ir: `${source.kind}s.${source.name}` }), + source, + }; +} + +const METHODS: Record = { + init: { + what: "The initial values of the variables the module drives, in order", + why: "From the initial marking.", + }, + next: { + what: "One step of the net: the statements, then the next values", + why: "Transitions sweep in order, consumption is immediate, production lands at the end.", + }, + flow: { what: "The continuous evolution between steps" }, +}; + +/** The methods of an SPN module: a step is a firing, and a flow runs the clocks. */ +const SPN_METHODS: Record = { + init: METHODS.init, + next: { + what: "A firing: the statements, then the next values", + why: "Time runs until the first clock expires; its transition re-arms the clock, toggles its event, and the places count.", + }, + flow: { + what: "The tangents between firings, one per driven variable", + why: "A clock counts down against t while its transition is enabled; an event flows 0 and stays still.", + }, +}; + +/** A method, from its `def` to its `return`. */ +export function methodProvenance( + language: ModuleGraph["language"], + method: MethodName, +): Provenance { + return (language === "spn" ? SPN_METHODS : METHODS)[method]; +} + +const RETURNS: Record = { + init: "The initial values, one per driven variable", + next: "The next values, one per driven variable, in the order the module drives them", + flow: "The tangents, one per driven variable, 0 where it does not move", +}; + +export function returnProvenance(method: MethodName): Provenance { + return { what: RETURNS[method] }; +} + +/** The line that binds `net`: one module's instance, or the start of the composition. */ +export function systemProvenance(composed: boolean, ir: PetriNetIr): Provenance { + return { + what: "The system", + why: composed + ? "Every module composed: a variable one module drives is awaited by the others, in an order the awaits allow." + : "The one module, driving every place.", + source: { kind: "net", name: ir.name }, + }; +} + +/** A line of a composition written one module per line, and its closing bracket. */ +export function compositionProvenance(ir: PetriNetIr): Provenance { + return { + what: "The system", + why: "The composition, one module per line.", + source: { kind: "net", name: ir.name }, + }; +} + +/** The `hide={...}` argument of the composition. */ +export function hiddenProvenance(ir: PetriNetIr): Provenance { + return { + what: "The clocks kept private", + why: "Each clock belongs to its transition; the events and the places stay in the interface for a controller or an observer to await.", + source: { kind: "net", name: ir.name }, + }; +} + +/** `place_A = Place_A(theory=LIA, ctrl=(A,), extl=(fire_Go,))`. */ +export function instanceProvenance(module: DescribedModule, theory: string): Provenance { + const reads = module.extl.join(", "); + return { + what: `An instance of ${module.className} in the ${theory} theory`, + why: `Drives ${module.ctrl.join(", ")}${reads === "" ? "" : ` and reads ${reads}`}.`, + source: module.source, + }; +} diff --git a/pocs/zeroth-playground/compiler/emit/emit.test.ts b/pocs/zeroth-playground/compiler/emit/emit.test.ts new file mode 100644 index 00000000..40c16891 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/emit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { lowerPetriNetIr } from "../lower/lower"; +import { resolveOptions } from "../options"; +import { birthDeathIr } from "../testing/birth-death.fixtures"; +import { cycle } from "../testing/small-nets.fixtures"; +import { emitPython } from "./emit"; + +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** The files of a net the lowering accepts, in the layout its options give. */ +function emitted(ir: PetriNetIr, options: CompilerOptions) { + const inForce = resolveOptions(options); + const lowered = lowerPetriNetIr(ir, inForce); + if (!lowered.ok) { + throw new Error(lowered.errors.map((error) => error.code).join(", ")); + } + return emitPython(lowered.graph, inForce.layout, ir); +} + +describe("emitPython", () => { + it("writes the whole program as net.py under the single layout", () => { + // GIVEN the modular cycle in one file + // WHEN it is emitted + const files = emitted(cycle, { shape: "modular" }); + // THEN net.py holds the classes and the system + expect(files.map((file) => file.path)).toEqual(["net.py"]); + expect(files[0]?.text).toContain("class Transition_Go(Module):"); + expect(files[0]?.text).toContain("net = compose("); + }); + + it("writes net.py and one file per module under the per-module layout", () => { + // GIVEN the birth-death net under clocks, one module per file + // WHEN it is emitted + const files = emitted(birthDeathIr, { rates: "clock", layout: "per-module" }); + const main = files[0]?.text; + // THEN net.py imports the classes and composes them, and each class file imports its own sugar + expect(files.map((file) => file.path)).toEqual([ + "net.py", + "transition_birth.py", + "transition_death.py", + "place_population.py", + ]); + expect(main).toContain( + "from zrth import SPN, Clock, Event, Nat, Var\nfrom zrth import Module as compose\n\nfrom transition_birth import Transition_Birth\nfrom transition_death import Transition_Death\nfrom place_population import Place_Population\n", + ); + expect(main).toContain(" hide={clk_Birth, clk_Death},\n"); + expect(main).not.toContain("class "); + const place = files.find((file) => file.path === "place_population.py"); + expect( + place?.text.startsWith( + "from zrth.sugar import Module, ite, fired\n\n\nclass Place_Population(Module):", + ), + ).toBe(true); + expect(place?.text).not.toContain("from zrth import"); + expect(place?.text).not.toContain("def flow("); + }); + + it("writes net.py and one file per module of the modular shape, each traced", () => { + // GIVEN the cycle, modular and one module per file + // WHEN it is emitted + const files = emitted(cycle, { shape: "modular", layout: "per-module" }); + const main = files[0]?.text; + // THEN net.py imports and composes the classes, and each class file holds its class alone + expect(files.map((file) => file.path)).toEqual([ + "net.py", + "transition_go.py", + "transition_back.py", + "place_a.py", + "place_b.py", + ]); + expect(main).toContain("from transition_go import Transition_Go"); + expect(main).toContain("from place_b import Place_B"); + expect(main).toContain("net = compose("); + expect(main).not.toContain("class "); + const placeA = files.find((file) => file.path === "place_a.py"); + expect(placeA?.text).toContain("from zrth.sugar import Module, X"); + expect(placeA?.text).toContain("class Place_A(Module):"); + expect(placeA?.text).not.toContain("Var("); + for (const file of files) { + expect(file.trace.length).toBeGreaterThan(0); + } + }); +}); diff --git a/pocs/zeroth-playground/compiler/emit/emit.ts b/pocs/zeroth-playground/compiler/emit/emit.ts new file mode 100644 index 00000000..1fec20ee --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/emit.ts @@ -0,0 +1,25 @@ +import { linearProgram } from "./linear"; +import { pythonFiles } from "./python"; +import { spnProgram } from "./spn"; + +import type { ModuleGraph } from "../graph/module-graph"; +import type { PetriNetIr } from "../ir/schema"; +import type { ResolvedOptions } from "../options"; +import type { CompiledFile } from "./python"; + +/** + * The graph as Python over `zrth.sugar`, printed in the dialect of its + * language: `net.py` alone, or under the per-module layout `net.py` first + * and one file per module. Each file carries its trace, written with it. + */ +export function emitPython( + graph: ModuleGraph, + layout: ResolvedOptions["layout"], + ir: PetriNetIr, +): CompiledFile[] { + return pythonFiles( + graph.language === "spn" ? spnProgram(graph) : linearProgram(graph), + layout, + ir, + ); +} diff --git a/pocs/zeroth-playground/compiler/emit/linear.test.ts b/pocs/zeroth-playground/compiler/emit/linear.test.ts new file mode 100644 index 00000000..e0b7a88d --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/linear.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; + +import { binary, bool, ite, not, num, ref, relu, scale } from "../graph/linear-graph"; +import { emitPython } from "./emit"; +import { moduleFileStem } from "./python"; + +import type { LinearGraph } from "../graph/linear-graph"; +import type { PetriNetIr } from "../ir/schema"; + +/** The document the graph stands for; the emitter reads only its name and places. */ +const ir: PetriNetIr = { name: "ops", kind: "plain", places: {}, transitions: {} }; + +/** One module whose two returns use every operator the linear graph has. */ +const graph: LinearGraph = { + language: "linear", + variables: [ + { name: "Level", sort: "real", role: "place" }, + { name: "Full", sort: "bool", role: "flag" }, + ], + modules: [ + { + className: "Ops", + instance: "ops", + source: { kind: "net", name: "ops" }, + docstring: "Every operator the graph has.", + theory: "LRA", + ctrl: ["Level", "Full"], + extl: [], + init: [num(0), bool(false)], + next: [], + returns: [ + // 0.5 * (Level - 1) + relu(2 - Level), the max form + binary( + "+", + scale(0.5, binary("-", ref("Level"), num(1))), + relu(binary("-", num(2), ref("Level"))), + ), + // ~(Level < 3) | ((ite(Full, 4, 0) == 4) & Full), a literal-only ite inside + binary( + "|", + not(binary("<", ref("Level"), num(3))), + binary("&", binary("==", ite(ref("Full"), num(4), num(0)), num(4)), ref("Full")), + ), + ], + }, + ], + root: { kind: "single", module: "ops" }, +}; + +describe("the linear emitter in one file", () => { + it("renders comparisons, logic, scaling, relu and literal branches under Python's precedence", () => { + // GIVEN the graph that uses every operator + // WHEN it is emitted as one file + const python = emitPython(graph, "single", ir)[0]?.text; + // THEN each operator prints with only the brackets Python needs + expect(python).toBe( + `from zrth import LRA, Bool, Real, Var, expr +from zrth.expr import relu +from zrth.sugar import Module, ite + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Level = Var(REAL) + +Full = Var(BOOL) + + +class Ops(Module): + """Every operator the graph has.""" + + def init(self): + return 0.0, False + + def next(self, Level, Full): + return 0.5 * (Level - 1.0) + relu(2.0 - Level), ~(Level < 3.0) | ((ite(Full, expr(4.0, theory=LRA, sort=REAL), 0.0) == 4.0) & Full) + + +net = Ops(theory=LRA, ctrl=(Level, Full)) +`, + ); + }); +}); + +describe("the linear emitter, one module per file", () => { + it("names a module's file after its class", () => { + // GIVEN class names in UpperCamelCase, with digits and with an acronym + const classNames = ["Transition_Infection", "Place_FooBar2", "Draw_ABTest"]; + // WHEN each is turned into a file stem + const stems = classNames.map((className) => moduleFileStem(className)); + // THEN each is in snake case + expect(stems[0]).toBe("transition_infection"); + expect(stems[1]).toBe("place_foo_bar2"); + expect(stems[2]).toBe("draw_ab_test"); + }); + + it("numbers the stem of a module whose class name folds onto an earlier one", () => { + // GIVEN three modules whose class names differ by case alone + function twin(className: string, instance: string) { + return { ...graph.modules[0]!, className, instance }; + } + const twins: LinearGraph = { + ...graph, + modules: [twin("Place_Ab", "a"), twin("Place_AB", "b"), twin("Place_ab", "c")], + root: { kind: "compose", modules: ["a", "b", "c"] }, + }; + // WHEN they are emitted one module per file + const files = emitPython(twins, "per-module", ir); + // THEN the later files are numbered, and net.py imports each class from its own file + expect(files.map((file) => file.path)).toEqual([ + "net.py", + "place_ab.py", + "place_ab_2.py", + "place_ab_3.py", + ]); + expect(files[0]?.text).toContain("from place_ab_2 import Place_AB"); + expect(files[2]?.text).toContain("class Place_AB(Module):"); + }); + + it("writes net.py first and gives a module the imports and sorts its own body needs", () => { + // GIVEN the graph + // WHEN it is emitted one module per file + const files = emitPython(graph, "per-module", ir); + // THEN net.py declares the variables and instantiates the module, + // and the module's file imports only what its body uses + expect(files.map((file) => file.path)).toEqual(["net.py", "ops.py"]); + expect(files[0]?.text).toBe( + `from zrth import LRA, Bool, Real, Var + +from ops import Ops + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Level = Var(REAL) + +Full = Var(BOOL) + + +net = Ops(theory=LRA, ctrl=(Level, Full)) +`, + ); + const [, ops] = files; + expect( + ops?.text.startsWith(`from zrth import LRA, Real, expr +from zrth.expr import relu +from zrth.sugar import Module, ite + +REAL = Real([1, 1]) + + +class Ops(Module): +`), + ).toBe(true); + expect(ops?.text).toContain("expr(4.0, theory=LRA, sort=REAL)"); + }); +}); diff --git a/pocs/zeroth-playground/compiler/emit/linear.ts b/pocs/zeroth-playground/compiler/emit/linear.ts new file mode 100644 index 00000000..bb61b81a --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/linear.ts @@ -0,0 +1,185 @@ +import { sortConstantProvenance } from "./describe"; +import { floatLiteral, named, sugarImport } from "./python"; +import { traced } from "./python-writer"; + +import type { + LinearExpr, + LinearGraph, + LinearModule, + LinearSort, + LinearVariable, + Theory, +} from "../graph/linear-graph"; +import type { PrintedModule, PrintedStatement, PythonProgram } from "./python"; +import type { PythonLine } from "./python-writer"; + +/** + * The linear dialect of the Python: LIA and LRA expressions over the + * `zrth.sugar` DSL, the sort constants every variable is declared with, + * and the imports each file needs. `python.ts` writes the rest. + */ + +const SORT_CONSTANTS: Record = { + int: { name: "INT", ctor: "Int" }, + real: { name: "REAL", ctor: "Real" }, + bool: { name: "BOOL", ctor: "Bool" }, +}; + +const SORT_ORDER: LinearSort[] = ["int", "real", "bool"]; +const ROLE_ORDER: LinearVariable["role"][] = ["place", "input", "flag"]; + +/** The sugar names a body can print, in the order the import lists them. */ +const SUGAR_NAMES = ["X", "ite"]; + +/** A Python literal: integers become floats where every sort is Real. */ +function literal(value: number, asFloat: boolean): string { + return asFloat ? floatLiteral(value) : `${value}`; +} + +const COMPARISONS = new Set(["<", "<=", ">", ">=", "==", "!="]); +const LOGICAL = new Set(["&", "|"]); + +/** + * An operand of `&` or `|`: a comparison or the other logical operator + * needs parentheses under Python's precedence, where `&` and `|` bind + * tighter than comparisons. + */ +function logicalOperand(node: LinearExpr, text: string, parent: "&" | "|"): string { + return node.kind === "binary" && + (COMPARISONS.has(node.op) || (LOGICAL.has(node.op) && node.op !== parent)) + ? `(${text})` + : text; +} + +/** An operand of `~` or of a scale: anything but a name, a literal or a call. */ +function tightOperand(node: LinearExpr, text: string): string { + return node.kind === "binary" ? `(${text})` : text; +} + +function isLiteral(node: LinearExpr): boolean { + return node.kind === "num" || node.kind === "bool"; +} + +/** + * The expression's text. Each sugar or zrth name it prints goes into + * `used`, and so does the sort constant a literal-only branch names. + */ +function expr(node: LinearExpr, theory: Theory, used: Set): string { + const asFloat = theory === "LRA"; + const print = (child: LinearExpr) => expr(child, theory, used); + switch (node.kind) { + case "ref": + return node.next ? `${named(used, "X")}(${node.name})` : node.name; + case "num": + return literal(node.value, asFloat); + case "bool": + return node.value ? "True" : "False"; + case "binary": { + const left = print(node.left); + const right = print(node.right); + return node.op === "&" || node.op === "|" + ? `${logicalOperand(node.left, left, node.op)} ${node.op} ${logicalOperand(node.right, right, node.op)}` + : `${left} ${node.op} ${right}`; + } + case "ite": { + // The sugar needs one branch to carry the theory and sort when both are literals. + const thenText = print(node.thenBranch); + const sort = node.thenBranch.kind === "bool" ? "BOOL" : asFloat ? "REAL" : "INT"; + const thenBranch = + isLiteral(node.thenBranch) && isLiteral(node.elseBranch) + ? `${named(used, "expr")}(${thenText}, theory=${theory}, sort=${named(used, sort)})` + : thenText; + return `${named(used, "ite")}(${print(node.condition)}, ${thenBranch}, ${print(node.elseBranch)})`; + } + case "not": + return `~${tightOperand(node.operand, print(node.operand))}`; + case "scale": + return `${literal(node.factor, asFloat)} * ${tightOperand(node.operand, print(node.operand))}`; + case "relu": + return `${named(used, "relu")}(${print(node.operand)})`; + } +} + +function theoriesOf(modules: LinearModule[]): Theory[] { + return [...new Set(modules.map((module) => module.theory))].toSorted(); +} + +function sortsOf(variables: LinearVariable[]): LinearSort[] { + return SORT_ORDER.filter((sort) => variables.some((variable) => variable.sort === sort)); +} + +function constructorNames(sorts: LinearSort[]): string[] { + return sorts.map((sort) => SORT_CONSTANTS[sort].ctor).toSorted(); +} + +function sortConstant(sort: LinearSort): PythonLine { + const { name, ctor } = SORT_CONSTANTS[sort]; + return traced(`${name} = ${ctor}([1, 1])`, sortConstantProvenance(ctor)); +} + +/** `relu` is the one name the bodies take from `zrth.expr`. */ +function reluImport(used: ReadonlySet): string[] { + return used.has("relu") ? ["from zrth.expr import relu"] : []; +} + +/** The imports and sort constants of a module's own file. */ +function moduleFile(theory: Theory, used: ReadonlySet): PrintedModule["file"] { + // A literal-only branch names the module's theory and a sort, which the + // file declares for itself: zrth sorts compare by shape, not identity. + const literalSorts = SORT_ORDER.filter((sort) => used.has(SORT_CONSTANTS[sort].name)); + return { + imports: [ + ...(literalSorts.length > 0 + ? [`from zrth import ${[theory, ...constructorNames(literalSorts), "expr"].join(", ")}`] + : []), + ...reluImport(used), + sugarImport(SUGAR_NAMES, used), + ], + sortConstants: literalSorts.map(sortConstant), + }; +} + +/** The module with its expressions printed, and the names they use. */ +function printedModule(module: LinearModule): { printed: PrintedModule; used: Set } { + const { theory } = module; + const used = new Set(); + const print = (node: LinearExpr) => expr(node, theory, used); + const init = module.init.map(print); + const next = module.next.map((statement): PrintedStatement => + statement.kind === "comment" ? statement : { ...statement, expr: print(statement.expr) }, + ); + const returns = module.returns.map(print); + return { printed: { ...module, init, next, returns, file: moduleFile(theory, used) }, used }; +} + +/** The graph as the shared skeleton writes it. */ +export function linearProgram(graph: LinearGraph): PythonProgram { + const sorts = sortsOf(graph.variables); + const printed = graph.modules.map(printedModule); + const used = new Set(printed.flatMap((module) => [...module.used])); + const compose = graph.root.kind === "compose" ? ["from zrth import Module as compose"] : []; + return { + language: "linear", + roles: ROLE_ORDER, + variables: graph.variables.map((variable) => ({ + ...variable, + sort: SORT_CONSTANTS[variable.sort].name, + })), + modules: printed.map((module) => module.printed), + system: + graph.root.kind === "single" + ? graph.root + : { kind: "compose", modules: graph.root.modules, hidden: [] }, + imports: [ + `from zrth import ${[...theoriesOf(graph.modules), ...constructorNames(sorts), "Var", ...(used.has("expr") ? ["expr"] : [])].join(", ")}`, + ...compose, + ...reluImport(used), + sugarImport(SUGAR_NAMES, used), + ], + mainImports: [ + `from zrth import ${[...theoriesOf(graph.modules), ...constructorNames(sorts), "Var"].join(", ")}`, + ...compose, + ], + sortConstants: sorts.map(sortConstant), + }; +} diff --git a/pocs/zeroth-playground/compiler/emit/python-writer.test.ts b/pocs/zeroth-playground/compiler/emit/python-writer.test.ts new file mode 100644 index 00000000..6f857464 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/python-writer.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { block, traced, written } from "./python-writer"; + +describe("written", () => { + it("joins the lines with a newline at the end, and traces each traced line alone", () => { + // GIVEN a traced line between two plain ones + const lines = ["from zrth import Var", traced("A = Var(INT)", { what: "The tokens in A" }), ""]; + // WHEN they are written + const { text, trace } = written(lines); + // THEN the text holds every line, and only the traced one has a range + expect(text).toBe("from zrth import Var\nA = Var(INT)\n\n"); + expect(trace).toEqual([{ startLine: 2, endLine: 2, provenance: { what: "The tokens in A" } }]); + }); + + it("spans a block from its first non-blank line to its last, before the ranges inside it", () => { + // GIVEN a class block holding a method block, with blank lines inside and after + const lines = [ + block({ what: "The class" }, [ + "class A(Module):", + "", + block({ what: "The method" }, [ + " def init(self):", + traced(" return 1", { what: "The return" }), + "", + ]), + "", + ]), + "", + traced("net = A()", { what: "The system" }), + ]; + // WHEN they are written + const { trace } = written(lines); + // THEN each block stops at its last non-blank line, and the outer range comes first + expect(trace).toEqual([ + { startLine: 1, endLine: 4, provenance: { what: "The class" } }, + { startLine: 3, endLine: 4, provenance: { what: "The method" } }, + { startLine: 4, endLine: 4, provenance: { what: "The return" } }, + { startLine: 8, endLine: 8, provenance: { what: "The system" } }, + ]); + }); + + it("gives a block of blank lines no range", () => { + // GIVEN a block that holds only a blank line + const lines = ["x = 1", block({ what: "Nothing" }, [""])]; + // WHEN they are written + const { text, trace } = written(lines); + // THEN the blank line is written, and nothing is traced + expect(text).toBe("x = 1\n\n"); + expect(trace).toEqual([]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/emit/python-writer.ts b/pocs/zeroth-playground/compiler/emit/python-writer.ts new file mode 100644 index 00000000..8a06a398 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/python-writer.ts @@ -0,0 +1,58 @@ +import type { Provenance, Trace } from "../trace/provenance"; + +/** + * Python written beside its trace. An emitter writes each line with what it + * is, and groups lines into blocks that say what they are together: the + * imports, a class, a method. The trace is read off what was written, so it + * cannot disagree with the text. + * + * A plain string is a line no range of its own holds: a blank, a docstring, + * the header that opens a block. + */ +export type PythonLine = string | TracedLine | PythonBlock; + +type TracedLine = { text: string; provenance: Provenance }; + +type PythonBlock = { provenance: Provenance; lines: readonly PythonLine[] }; + +export function traced(text: string, provenance: Provenance): PythonLine { + return { text, provenance }; +} + +export function block(provenance: Provenance, lines: readonly PythonLine[]): PythonLine { + return { provenance, lines }; +} + +/** + * The lines as text, ending in a newline, and their trace. A traced line's + * range is the line; a block's runs from its first non-blank line to its + * last, and comes before the ranges inside it. + */ +export function written(lines: readonly PythonLine[]): { text: string; trace: Trace } { + const texts: string[] = []; + const trace: Trace = []; + function write(line: PythonLine): void { + if (typeof line === "string") { + texts.push(line); + return; + } + if ("text" in line) { + texts.push(line.text); + trace.push({ startLine: texts.length, endLine: texts.length, provenance: line.provenance }); + return; + } + const at = trace.length; + const first = texts.length; + line.lines.forEach(write); + const filled = texts + .slice(first) + .flatMap((text, offset) => (text.trim() === "" ? [] : [first + offset + 1])); + const startLine = filled[0]; + const endLine = filled.at(-1); + if (startLine !== undefined && endLine !== undefined) { + trace.splice(at, 0, { startLine, endLine, provenance: line.provenance }); + } + } + lines.forEach(write); + return { text: `${texts.join("\n")}\n`, trace }; +} diff --git a/pocs/zeroth-playground/compiler/emit/python.test.ts b/pocs/zeroth-playground/compiler/emit/python.test.ts new file mode 100644 index 00000000..cbddd46d --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/python.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { composeLines, moduleFileStems } from "./python"; +import { written } from "./python-writer"; + +import type { PetriNetIr } from "../ir/schema"; + +const ir: PetriNetIr = { name: "birth_death", kind: "stochastic", places: {}, transitions: {} }; + +/** The lines as they are written, one string each. */ +function textOf(lines: Parameters[0]): string[] { + return written(lines).text.split("\n").slice(0, -1); +} + +describe("composeLines", () => { + it("composes on one line while it fits, with the hidden variables last", () => { + // GIVEN two instances, then the same two with a hidden clock + const instances = ["a", "b"]; + // WHEN each is composed + const plain = textOf(composeLines(instances, [], ir)); + const hiding = textOf(composeLines(instances, ["clk_a"], ir)); + // THEN each fits on one line, the hide set last + expect(plain).toEqual(["net = compose(a, b)"]); + expect(hiding).toEqual(["net = compose(a, b, hide={clk_a})"]); + }); + + it("puts one argument per line past the line width, the hide set as the last one", () => { + // GIVEN three instances whose compose line runs past the width with a hide set, + // and four whose line runs past it without one + const instances = ["transition_Birth", "transition_Death", "place_Population"]; + // WHEN each is composed + const hiding = textOf(composeLines(instances, ["clk_Birth", "clk_Death"], ir)); + const plain = textOf(composeLines([...instances, "place_Graveyard_Of_Names"], [], ir)); + // THEN each argument gets its own line + expect(hiding).toEqual([ + "net = compose(", + " transition_Birth,", + " transition_Death,", + " place_Population,", + " hide={clk_Birth, clk_Death},", + ")", + ]); + expect(plain).toEqual([ + "net = compose(", + " transition_Birth,", + " transition_Death,", + " place_Population,", + " place_Graveyard_Of_Names,", + ")", + ]); + }); + it("traces the opening line as the system, each module line as the composition, and the hide set as the private clocks", () => { + // GIVEN three instances and two hidden clocks, too long for one line + const lines = composeLines( + ["transition_Birth", "transition_Death", "place_Population"], + ["clk_Birth", "clk_Death"], + ir, + ); + // WHEN they are written + const { trace } = written(lines); + // THEN every line has its own range, all of them from the net + expect(trace.map(({ startLine, provenance }) => [startLine, provenance.what])).toEqual([ + [1, "The system"], + [2, "The system"], + [3, "The system"], + [4, "The system"], + [5, "The clocks kept private"], + [6, "The system"], + ]); + expect(trace[0]?.provenance.why).toContain("Every module composed"); + expect(trace[1]?.provenance.why).toBe("The composition, one module per line."); + expect(trace.every(({ provenance }) => provenance.source?.name === "birth_death")).toBe(true); + }); +}); + +describe("moduleFileStems", () => { + it("numbers a stem an earlier class already took", () => { + // GIVEN three class names that differ by case alone + const classNames = ["Place_Ab", "Place_AB", "Place_ab"]; + // WHEN each gets a file stem + const stems = moduleFileStems(classNames); + // THEN the later ones are numbered + expect([...stems]).toEqual([ + ["Place_Ab", "place_ab"], + ["Place_AB", "place_ab_2"], + ["Place_ab", "place_ab_3"], + ]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/emit/python.ts b/pocs/zeroth-playground/compiler/emit/python.ts new file mode 100644 index 00000000..eb918eee --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/python.ts @@ -0,0 +1,321 @@ +import { + commentProvenance, + compositionProvenance, + declarationProvenance, + hiddenProvenance, + IMPORTS, + instanceProvenance, + methodProvenance, + moduleProvenance, + returnProvenance, + statementProvenance, + systemProvenance, +} from "./describe"; +import { block, traced, written } from "./python-writer"; + +import type { ModuleGraph } from "../graph/module-graph"; +import type { NetItem } from "../ir/net-item"; +import type { PetriNetIr } from "../ir/schema"; +import type { ResolvedOptions } from "../options"; +import type { Trace } from "../trace/provenance"; +import type { MethodName } from "./describe"; +import type { PythonLine } from "./python-writer"; + +/** + * The Python every module graph prints to, whatever its theory: the + * declarations, a class per module with `init`, `next` and an optional + * `flow`, the instances and the composition, in one file or one file per + * module. A theory's emitter prints its expressions and writes its imports + * and sort constants into a `PythonProgram`; this file writes the rest. + */ + +/** One emitted Python file with the trace over its lines. */ +export type CompiledFile = { + /** Relative to the output directory, `net.py` for the main file. */ + path: string; + text: string; + trace: Trace; +}; + +/** A variable as it is declared: `Pool = Var(INT)`. */ +export type PrintedVariable = { + name: string; + role: string; + /** The argument of its `Var(...)`: `INT`, `Nat()`. */ + sort: string; + comment?: string; +}; + +export type PrintedStatement = + | { kind: "comment"; text: string } + | { kind: "assign"; target: string; expr: string; comment?: string }; + +/** A module with its expressions printed. */ +export type PrintedModule = { + className: string; + instance: string; + docstring: string; + source: NetItem; + draw?: true; + /** The theory its instance is built in: `LIA`, `LRA`, `SPN`. */ + theory: string; + ctrl: readonly string[]; + extl: readonly string[]; + init: readonly string[]; + next: readonly PrintedStatement[]; + returns: readonly string[]; + flow?: readonly string[]; + /** The head of the module's own file: its imports, and the sort constants its body names. */ + file: { imports: readonly string[]; sortConstants: readonly PythonLine[] }; +}; + +/** How the modules make up the system: one module, or all of them composed. */ +export type PythonSystem = + | { kind: "single"; module: string } + | { kind: "compose"; modules: readonly string[]; hidden: readonly string[] }; + +/** A module graph as its theory prints it, for the skeleton to write out. */ +export type PythonProgram = { + language: ModuleGraph["language"]; + /** The declarations are grouped by role, in this order. */ + roles: readonly string[]; + variables: readonly PrintedVariable[]; + modules: readonly PrintedModule[]; + system: PythonSystem; + /** The imports of `net.py` written as one file. */ + imports: readonly string[]; + /** The imports of `net.py` beside one file per module, before the imports of those modules. */ + mainImports: readonly string[]; + /** The sort constants `net.py` declares after its imports; `null` for a theory that has none. */ + sortConstants: readonly PythonLine[] | null; +}; + +export const INDENT = " "; +export const LINE_WIDTH = 88; + +/** A Python float literal: an integer is written with `.0`. */ +export function floatLiteral(value: number): string { + return Number.isInteger(value) ? `${value}.0` : `${value}`; +} + +/** `name`, recorded in `used`: each dialect prints its sugar and zrth names through it, so the imports list what the bodies print. */ +export function named(used: Set, name: string): string { + used.add(name); + return name; +} + +/** `from zrth.sugar import Module, ...`: then each of `names` the bodies use, in the order given. */ +export function sugarImport(names: readonly string[], used: ReadonlySet): string { + return `from zrth.sugar import ${["Module", ...names.filter((name) => used.has(name))].join(", ")}`; +} + +export function tupleLiteral(names: readonly string[]): string { + return names.length === 1 ? `(${names[0]},)` : `(${names.join(", ")})`; +} + +/** + * `net = compose(...)` over the instances, with `hide={...}` last when some + * variables are hidden: one line when it fits, one argument per line otherwise. + */ +export function composeLines( + instances: readonly string[], + hidden: readonly string[], + ir: PetriNetIr, +): PythonLine[] { + const hide = hidden.length > 0 ? [`hide={${hidden.join(", ")}}`] : []; + const oneLine = `net = compose(${[...instances, ...hide].join(", ")})`; + if (oneLine.length <= LINE_WIDTH) { + return [traced(oneLine, systemProvenance(true, ir))]; + } + return [ + traced("net = compose(", systemProvenance(true, ir)), + ...instances.map((instance) => traced(`${INDENT}${instance},`, compositionProvenance(ir))), + ...hide.map((arg) => traced(`${INDENT}${arg},`, hiddenProvenance(ir))), + traced(")", compositionProvenance(ir)), + ]; +} + +/** `Transition_FooBar` → `transition_foo_bar`: the Python module a class file imports as. */ +export function moduleFileStem(className: string): string { + return className + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +/** + * One stem per module. IR names differ by case alone at times, `Ab` and `AB`, + * and lowercasing joins them, so a later module that lands on a taken stem + * gets a numbered one: `place_ab`, `place_ab_2`. + */ +export function moduleFileStems(classNames: readonly string[]): ReadonlyMap { + const taken = new Set(); + const stems = new Map(); + for (const className of classNames) { + const base = moduleFileStem(className); + let stem = base; + for (let index = 2; taken.has(stem); index += 1) { + stem = `${base}_${index}`; + } + taken.add(stem); + stems.set(className, stem); + } + return stems; +} + +/** The declarations in groups by role, a blank line between groups. */ +function declarations(program: PythonProgram, ir: PetriNetIr): PythonLine[] { + const groups = program.roles + .map((role) => + program.variables + .filter((variable) => variable.role === role) + .map((variable) => + traced( + `${variable.name} = Var(${variable.sort})${variable.comment === undefined ? "" : ` # ${variable.comment}`}`, + declarationProvenance(variable, ir), + ), + ), + ) + .filter((group) => group.length > 0); + return groups.flatMap((group, index) => (index === 0 ? group : ["", ...group])); +} + +function statementLine(statement: PrintedStatement, ir: PetriNetIr): PythonLine { + if (statement.kind === "comment") { + return traced(`${INDENT}${INDENT}# ${statement.text}`, commentProvenance(statement.text)); + } + const trailer = statement.comment === undefined ? "" : ` # ${statement.comment}`; + return traced( + `${INDENT}${INDENT}${statement.target} = ${statement.expr}${trailer}`, + statementProvenance(statement, ir), + ); +} + +function method( + language: PythonProgram["language"], + name: MethodName, + parameters: readonly string[], + body: readonly PythonLine[], + returns: readonly string[], +): PythonLine { + return block(methodProvenance(language, name), [ + `${INDENT}def ${name}(${parameters.join(", ")}):`, + ...body, + traced(`${INDENT}${INDENT}return ${returns.join(", ")}`, returnProvenance(name)), + ]); +} + +function classLines( + language: PythonProgram["language"], + module: PrintedModule, + ir: PetriNetIr, +): PythonLine { + const parameters = ["self", ...module.ctrl, ...module.extl]; + const { flow } = module; + return block(moduleProvenance(module), [ + `class ${module.className}(Module):`, + `${INDENT}"""${module.docstring}"""`, + "", + method(language, "init", ["self", ...module.extl], [], module.init), + "", + method( + language, + "next", + parameters, + module.next.map((statement) => statementLine(statement, ir)), + module.returns, + ), + ...(flow === undefined ? [] : ["", method(language, "flow", parameters, [], flow)]), + ]); +} + +function construction(module: PrintedModule): string { + const extl = module.extl.length > 0 ? `, extl=${tupleLiteral(module.extl)}` : ""; + return `${module.className}(theory=${module.theory}, ctrl=${tupleLiteral(module.ctrl)}${extl})`; +} + +/** The lines that bind `net`: the one module's instance, or each instance and their composition. */ +function systemLines(program: PythonProgram, ir: PetriNetIr): PythonLine[] { + const byInstance = new Map(program.modules.map((module) => [module.instance, module])); + function moduleOf(instance: string): PrintedModule { + const module = byInstance.get(instance); + if (module === undefined) { + throw new Error(`the root names module ${instance}, which does not exist`); + } + return module; + } + const { system } = program; + if (system.kind === "single") { + return [traced(`net = ${construction(moduleOf(system.module))}`, systemProvenance(false, ir))]; + } + return [ + ...system.modules.map((instance) => { + const module = moduleOf(instance); + return traced( + `${instance} = ${construction(module)}`, + instanceProvenance(module, module.theory), + ); + }), + ...composeLines(system.modules, system.hidden, ir), + ]; +} + +/** + * The program as `net.py` alone, or as `net.py` first and one file per + * module. Run from the directory the files are written to, `net.py` + * imports each module by its file name. + */ +export function pythonFiles( + program: PythonProgram, + layout: ResolvedOptions["layout"], + ir: PetriNetIr, +): CompiledFile[] { + const sortConstants = program.sortConstants === null ? [] : [...program.sortConstants, ""]; + const declared = declarations(program, ir); + const system = systemLines(program, ir); + if (layout === "single") { + const lines = [ + block(IMPORTS, program.imports), + "", + ...sortConstants, + ...declared, + ...program.modules.flatMap((module) => ["", "", classLines(program.language, module, ir)]), + "", + "", + ...system, + ]; + return [{ path: "net.py", ...written(lines) }]; + } + const stems = moduleFileStems(program.modules.map((module) => module.className)); + function stemOf(className: string): string { + return stems.get(className) ?? moduleFileStem(className); + } + const main = [ + block(IMPORTS, [ + ...program.mainImports, + "", + ...program.modules.map( + (module) => `from ${stemOf(module.className)} import ${module.className}`, + ), + ]), + "", + ...sortConstants, + ...declared, + "", + "", + ...system, + ]; + return [ + { path: "net.py", ...written(main) }, + ...program.modules.map((module) => { + const lines = [ + block(IMPORTS, module.file.imports), + ...(module.file.sortConstants.length > 0 ? ["", ...module.file.sortConstants] : []), + "", + "", + classLines(program.language, module, ir), + ]; + return { path: `${stemOf(module.className)}.py`, ...written(lines) }; + }), + ]; +} diff --git a/pocs/zeroth-playground/compiler/emit/spn.test.ts b/pocs/zeroth-playground/compiler/emit/spn.test.ts new file mode 100644 index 00000000..7eda68f6 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/spn.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from "vitest"; + +import { + and, + assign, + bool, + dec, + exp, + fired, + ifThen, + inc, + isZero, + ite, + nat, + nonNegative, + nonZero, + not, + rate, + ref, + zeroFlow, +} from "../graph/spn-graph"; +import { emitPython } from "./emit"; + +import type { SpnGraph } from "../graph/spn-graph"; +import type { PetriNetIr } from "../ir/schema"; + +/** The document the graph stands for; the emitter reads only its name and places. */ +const ir: PetriNetIr = { name: "spn", kind: "stochastic", places: {}, transitions: {} }; + +/** A transition with a clock and an event, and a place that uses every operator of its side. */ +const graph: SpnGraph = { + language: "spn", + variables: [ + { name: "P", sort: "nat", role: "place" }, + { name: "t", sort: "clock", role: "time", comment: "the time reference" }, + { + name: "clk_T", + sort: "clock", + role: "clock", + comment: "time left until T fires", + }, + { + name: "ev_T", + sort: "event", + role: "event", + comment: "toggles when T fires", + }, + ], + modules: [ + { + className: "Transition_T", + instance: "transition_T", + source: { kind: "transition", name: "T" }, + docstring: "T: P -> P, at rate 0.5", + ctrl: ["clk_T", "ev_T"], + extl: ["P", "t"], + init: [exp(0.5), bool(false)], + next: [assign("fires_T", and(isZero(ref("clk_T")), nonZero(ref("P"))))], + returns: [ + ite(ref("fires_T"), exp(0.5), ref("clk_T")), + ifThen(ref("fires_T"), not(ref("ev_T"))), + ], + flow: [ + ifThen(nonNegative("clk_T"), ite(nonZero(ref("P")), rate(-1, "t"), rate(0, "t"))), + zeroFlow(), + ], + }, + { + className: "Place_P", + instance: "place_P", + source: { kind: "place", name: "P" }, + docstring: "P: every operator the place side has", + ctrl: ["P"], + extl: ["ev_T"], + init: [nat(3)], + next: [assign("fired_T", fired("ev_T"))], + returns: [ + // ~(a & b) & (P != 0) parenthesised, a step inside a test, a test inside a not + ite( + and(not(and(ref("fired_T"), not(isZero(ref("P"))))), nonZero(ref("P"))), + dec(ref("P")), + ite(isZero(inc(ref("P"))), inc(ref("P")), ref("P")), + ), + ], + }, + ], + hidden: ["clk_T"], +}; + +describe("the SPN emitter in one file", () => { + it("writes the SPN sorts, the sugar it uses, next and flow, and the hidden clocks", () => { + // GIVEN the transition and place graph + // WHEN it is emitted as one file + const python = emitPython(graph, "single", ir)[0]?.text; + // THEN it declares the sorts, writes next and flow, and hides the clock in compose + expect(python).toBe( + `from zrth import SPN, Clock, Event, Nat, Var +from zrth import Module as compose +from zrth.sugar import Module, d, ite, fired, exp + +t = Var(Clock()) # the time reference + +P = Var(Nat()) + +clk_T = Var(Clock()) # time left until T fires + +ev_T = Var(Event()) # toggles when T fires + + +class Transition_T(Module): + """T: P -> P, at rate 0.5""" + + def init(self, P, t): + return exp(0.5), False + + def next(self, clk_T, ev_T, P, t): + fires_T = (clk_T == 0) & (P != 0) + return ite(fires_T, exp(0.5), clk_T), ite(fires_T, ~ev_T, None) + + def flow(self, clk_T, ev_T, P, t): + return ite(clk_T >= 0, ite(P != 0, -1 * d(t), 0 * d(t)), None), 0 + + +class Place_P(Module): + """P: every operator the place side has""" + + def init(self, ev_T): + return 3 + + def next(self, P, ev_T): + fired_T = fired(ev_T) + return ite(~(fired_T & ~(P == 0)) & (P != 0), P - 1, ite((P + 1) == 0, P + 1, P)) + + +transition_T = Transition_T(theory=SPN, ctrl=(clk_T, ev_T), extl=(P, t)) +place_P = Place_P(theory=SPN, ctrl=(P,), extl=(ev_T,)) +net = compose(transition_T, place_P, hide={clk_T}) +`, + ); + }); + + it("declares a pick as a Bool of one value, imported beside the SPN sorts", () => { + // GIVEN the graph with a pick for T + const picked: SpnGraph = { + ...graph, + variables: [ + ...graph.variables, + { + name: "pick_T", + sort: "bool", + role: "pick", + comment: "the environment lets T fire this step", + }, + ], + }; + // WHEN it is emitted + const python = emitPython(picked, "single", ir)[0]?.text; + // THEN Bool is imported and the pick is declared after the events + expect(python).toContain("from zrth import SPN, Bool, Clock, Event, Nat, Var\n"); + expect(python).toContain( + "ev_T = Var(Event()) # toggles when T fires\n\npick_T = Var(Bool([1, 1])) # the environment lets T fire this step\n\n\nclass Transition_T(Module):", + ); + }); + + it("imports only the sorts and sugar names a graph uses, and writes an integer rate as a float", () => { + // GIVEN a place no transition moves, then the same place armed with an integer rate + const counter: SpnGraph = { + language: "spn", + variables: [{ name: "N", sort: "nat", role: "place" }], + modules: [ + { + className: "Place_N", + instance: "place_N", + source: { kind: "place", name: "N" }, + docstring: "N: no transition moves its tokens", + ctrl: ["N"], + extl: [], + init: [nat(0)], + next: [], + returns: [ref("N")], + }, + ], + hidden: [], + }; + const armed: SpnGraph = { + ...counter, + modules: [{ ...counter.modules[0]!, init: [exp(2)] }], + }; + // WHEN each is emitted + const python = emitPython(counter, "single", ir)[0]?.text; + const armedPython = emitPython(armed, "single", ir)[0]?.text; + // THEN the imports name only what is used, there is no flow, and the rate is a float + expect(python).toContain("from zrth import SPN, Nat, Var\n"); + expect(python).toContain("from zrth.sugar import Module\n"); + expect(python).toContain("net = compose(place_N)\n"); + expect(python).not.toContain("def flow("); + expect(armedPython).toContain(" return exp(2.0)\n"); + }); +}); + +describe("the SPN emitter, one module per file", () => { + it("writes net.py with the imports and the composition, and a class file with its own sugar", () => { + // GIVEN the transition and place graph + // WHEN it is emitted one module per file + const files = emitPython(graph, "per-module", ir); + // THEN net.py declares and composes, and each class file imports only its own sugar + expect(files.map((file) => file.path)).toEqual(["net.py", "transition_t.py", "place_p.py"]); + expect(files[0]?.text).toBe( + `from zrth import SPN, Clock, Event, Nat, Var +from zrth import Module as compose + +from transition_t import Transition_T +from place_p import Place_P + +t = Var(Clock()) # the time reference + +P = Var(Nat()) + +clk_T = Var(Clock()) # time left until T fires + +ev_T = Var(Event()) # toggles when T fires + + +transition_T = Transition_T(theory=SPN, ctrl=(clk_T, ev_T), extl=(P, t)) +place_P = Place_P(theory=SPN, ctrl=(P,), extl=(ev_T,)) +net = compose(transition_T, place_P, hide={clk_T}) +`, + ); + expect( + files[2]?.text.startsWith(`from zrth.sugar import Module, ite, fired + + +class Place_P(Module): +`), + ).toBe(true); + expect(files[1]?.text).toContain("from zrth.sugar import Module, d, ite, exp\n"); + expect(files[1]?.text).not.toContain("from zrth import"); + }); +}); diff --git a/pocs/zeroth-playground/compiler/emit/spn.ts b/pocs/zeroth-playground/compiler/emit/spn.ts new file mode 100644 index 00000000..f7994467 --- /dev/null +++ b/pocs/zeroth-playground/compiler/emit/spn.ts @@ -0,0 +1,126 @@ +import { floatLiteral, named, sugarImport } from "./python"; + +import type { SpnExpr, SpnGraph, SpnModule, SpnSort, SpnVariable } from "../graph/spn-graph"; +import type { PrintedModule, PythonProgram } from "./python"; + +/** + * The SPN dialect of the Python, in the form of Zeroth's own + * `birth_death.py`: SPN expressions over the `zrth.sugar` DSL, each + * variable declared with its SPN sort, and the imports each file needs. + * The system composes every module with the clocks hidden. `python.ts` + * writes the rest, `flow` included on a module that has one. + */ + +/** Each sort's constructor: the name imported from zrth, and the call that makes the sort. */ +const SPN_SORTS: Record = { + nat: { name: "Nat", call: "Nat()" }, + clock: { name: "Clock", call: "Clock()" }, + event: { name: "Event", call: "Event()" }, + bool: { name: "Bool", call: "Bool([1, 1])" }, +}; + +const ROLE_ORDER: SpnVariable["role"][] = ["time", "place", "clock", "event", "pick"]; + +/** An operand of `&`: a comparison needs parentheses, since `&` binds tighter. */ +function logicalOperand(node: SpnExpr, text: string): string { + return node.kind === "test" || node.kind === "nonNegative" ? `(${text})` : text; +} + +/** An operand of `~`, of a test or of a step: anything but a name, a literal or a call. */ +function tightOperand(node: SpnExpr, text: string): string { + return node.kind === "logic" || + node.kind === "test" || + node.kind === "step" || + node.kind === "nonNegative" || + node.kind === "rate" + ? `(${text})` + : text; +} + +/** The expression's text. Each sugar name it prints goes into `used`. */ +function expr(node: SpnExpr, used: Set): string { + const print = (child: SpnExpr) => expr(child, used); + switch (node.kind) { + case "ref": + return node.name; + case "nat": + return `${node.value}`; + case "bool": + return node.value ? "True" : "False"; + case "test": + return `${tightOperand(node.operand, print(node.operand))} ${node.zero ? "==" : "!="} 0`; + case "logic": + return `${logicalOperand(node.left, print(node.left))} ${node.op} ${logicalOperand(node.right, print(node.right))}`; + case "not": + return `~${tightOperand(node.operand, print(node.operand))}`; + case "step": + return `${tightOperand(node.operand, print(node.operand))} ${node.op} 1`; + case "ite": + return `${named(used, "ite")}(${print(node.condition)}, ${print(node.thenBranch)}, ${print(node.elseBranch)})`; + case "ifThen": + return `${named(used, "ite")}(${print(node.condition)}, ${print(node.branch)}, None)`; + case "fired": + return `${named(used, "fired")}(${node.event})`; + case "exp": + return `${named(used, "exp")}(${floatLiteral(node.rate)})`; + case "nonNegative": + return `${node.clock} >= 0`; + case "rate": + return `${node.factor} * ${named(used, "d")}(${node.time})`; + case "zeroFlow": + return "0"; + } +} + +/** The sugar names a body can print, in the order the reference file lists them. */ +const SUGAR_NAMES = ["d", "ite", "fired", "exp"]; + +/** `from zrth import SPN, Clock, Event, Nat, Var`, the sorts the variables take. */ +function zrthImport(variables: SpnVariable[]): string { + const sorts = new Set(variables.map((variable) => variable.sort)); + const constructors = [...sorts].map((sort) => SPN_SORTS[sort].name).toSorted(); + return `from zrth import ${["SPN", ...constructors, "Var"].join(", ")}`; +} + +/** The module with its expressions printed, and the names they use. */ +function printedModule(module: SpnModule): { printed: PrintedModule; used: Set } { + const { flow, ...rest } = module; + const used = new Set(); + const print = (node: SpnExpr) => expr(node, used); + const printed = { + ...rest, + theory: "SPN", + init: module.init.map(print), + next: module.next.map((statement) => ({ ...statement, expr: print(statement.expr) })), + returns: module.returns.map(print), + ...(flow === undefined ? {} : { flow: flow.map(print) }), + }; + return { + printed: { ...printed, file: { imports: [sugarImport(SUGAR_NAMES, used)], sortConstants: [] } }, + used, + }; +} + +/** The graph as the shared skeleton writes it. */ +export function spnProgram(graph: SpnGraph): PythonProgram { + const zrth = [zrthImport(graph.variables), "from zrth import Module as compose"]; + const printed = graph.modules.map(printedModule); + const used = new Set(printed.flatMap((module) => [...module.used])); + return { + language: "spn", + roles: ROLE_ORDER, + variables: graph.variables.map((variable) => ({ + ...variable, + sort: SPN_SORTS[variable.sort].call, + })), + modules: printed.map((module) => module.printed), + system: { + kind: "compose", + modules: graph.modules.map((module) => module.instance), + hidden: graph.hidden, + }, + imports: [...zrth, sugarImport(SUGAR_NAMES, used)], + mainImports: zrth, + sortConstants: null, + }; +} diff --git a/pocs/zeroth-playground/compiler/graph/linear-graph.ts b/pocs/zeroth-playground/compiler/graph/linear-graph.ts new file mode 100644 index 00000000..9054042d --- /dev/null +++ b/pocs/zeroth-playground/compiler/graph/linear-graph.ts @@ -0,0 +1,194 @@ +import type { NetItem } from "../ir/net-item"; + +/** + * The reactive module graph: what a compiler lowers a net to before any + * target syntax is chosen. A graph is a set of typed variables, a set of + * modules that each drive some of them and read others, and a root that + * says how the modules make up the system. + * + * The model follows Zeroth's reactive modules: a variable has one driver; + * a module's `next` computes each driven variable's next value from the + * latched values of the variables it reads, or from the next value of a + * variable another module drives in the same round (`next: true` on a + * reference, an await). Awaits must form a DAG, which composition orders. + */ + +export type LinearSort = "int" | "real" | "bool"; + +/** The theory a module's expressions are typed in. */ +export type Theory = "LIA" | "LRA"; + +export type LinearVariable = { + name: string; + sort: LinearSort; + /** + * `place`: a token count. `input`: an external the harness writes each + * round, such as a draw or a choice. `flag`: a Bool a module drives for + * the others to await. + */ + role: "place" | "input" | "flag"; + comment?: string; +}; + +/** + * Arithmetic on numbers, comparisons from numbers to Bool, and `&`/`|` on + * Bool. Multiplication exists only by a constant, as `scale`, because the + * theories are linear. + */ +export type LinearBinaryOperator = "+" | "-" | "<" | "<=" | ">" | ">=" | "==" | "!=" | "&" | "|"; + +export type LinearExpr = + | { + kind: "ref"; + name: string; + /** The variable's next value, awaited from its driver; latched when false. */ + next: boolean; + } + | { kind: "num"; value: number } + | { kind: "bool"; value: boolean } + | { + kind: "binary"; + op: LinearBinaryOperator; + left: LinearExpr; + right: LinearExpr; + } + | { + kind: "ite"; + condition: LinearExpr; + thenBranch: LinearExpr; + elseBranch: LinearExpr; + } + | { kind: "not"; operand: LinearExpr } + /** `factor * operand`, the one multiplication a linear theory has. */ + | { kind: "scale"; factor: number; operand: LinearExpr } + /** `max(0, operand)`; `max`, `min` and `abs` are written with it. */ + | { kind: "relu"; operand: LinearExpr }; + +export type LinearStatement = + | { kind: "comment"; text: string } + /** Binds a local; a local named like a variable shadows it from here on. */ + | { kind: "assign"; target: string; expr: LinearExpr; comment?: string }; + +export type LinearModule = { + className: string; + /** The identifier the instance is bound to when the root composes it. */ + instance: string; + /** The net item the module stands for: the whole net, a place or a transition. */ + source: NetItem; + /** A transition's draw module, which stands for the transition beside its own module. */ + draw?: true; + docstring: string; + theory: Theory; + /** Variables the module drives, in parameter order. */ + ctrl: string[]; + /** Variables the module reads but does not drive, in parameter order. */ + extl: string[]; + /** One initial value per `ctrl`. */ + init: LinearExpr[]; + next: LinearStatement[]; + /** One next value per `ctrl`, read after `next`'s statements. */ + returns: LinearExpr[]; +}; + +export type LinearGraph = { + /** A graph in a linear theory; the SPN module graph is the other language. */ + language: "linear"; + variables: LinearVariable[]; + modules: LinearModule[]; + /** The system: one module's instance, or the composition of them all. */ + root: { kind: "single"; module: string } | { kind: "compose"; modules: string[] }; +}; + +export function ref(name: string): LinearExpr { + return { kind: "ref", name, next: false }; +} + +export function next(name: string): LinearExpr { + return { kind: "ref", name, next: true }; +} + +export function num(value: number): LinearExpr { + return { kind: "num", value }; +} + +export function bool(value: boolean): LinearExpr { + return { kind: "bool", value }; +} + +export function binary(op: LinearBinaryOperator, left: LinearExpr, right: LinearExpr): LinearExpr { + return { kind: "binary", op, left, right }; +} + +export function ite( + condition: LinearExpr, + thenBranch: LinearExpr, + elseBranch: LinearExpr, +): LinearExpr { + return { kind: "ite", condition, thenBranch, elseBranch }; +} + +export function not(operand: LinearExpr): LinearExpr { + return { kind: "not", operand }; +} + +export function scale(factor: number, operand: LinearExpr): LinearExpr { + return { kind: "scale", factor, operand }; +} + +export function relu(operand: LinearExpr): LinearExpr { + return { kind: "relu", operand }; +} + +/** `max(a, b)` as `a + relu(b - a)`. */ +export function max(left: LinearExpr, right: LinearExpr): LinearExpr { + return binary("+", left, relu(binary("-", right, left))); +} + +/** `min(a, b)` as `a - relu(a - b)`. */ +export function min(left: LinearExpr, right: LinearExpr): LinearExpr { + return binary("-", left, relu(binary("-", left, right))); +} + +/** The terms joined with `|`; `null` for no terms. */ +export function disjunction(terms: LinearExpr[]): LinearExpr | null { + return terms.reduce( + (any, term) => (any === null ? term : binary("|", any, term)), + null, + ); +} + +/** `current` changed by `amount` when `condition` holds; unconditionally without one. */ +export function changedWhen( + condition: LinearExpr | null, + current: LinearExpr, + op: "+" | "-", + amount: number, +): LinearExpr { + const changed = binary(op, current, num(amount)); + return condition === null ? changed : ite(condition, changed, current); +} + +/** `current` changed by a signed `delta` when `condition` holds. */ +export function changedBy( + condition: LinearExpr | null, + current: LinearExpr, + delta: number, +): LinearExpr { + return changedWhen(condition, current, delta > 0 ? "+" : "-", Math.abs(delta)); +} + +/** The terms joined with `&`; `null` for no terms. */ +export function conjunction(terms: LinearExpr[]): LinearExpr | null { + return terms.reduce( + (guard, term) => (guard === null ? term : binary("&", guard, term)), + null, + ); +} + +export function comment(text: string): LinearStatement { + return { kind: "comment", text }; +} + +export function assign(target: string, expr: LinearExpr, trailer?: string): LinearStatement { + return { kind: "assign", target, expr, ...(trailer === undefined ? {} : { comment: trailer }) }; +} diff --git a/pocs/zeroth-playground/compiler/graph/module-graph.ts b/pocs/zeroth-playground/compiler/graph/module-graph.ts new file mode 100644 index 00000000..bf505bb6 --- /dev/null +++ b/pocs/zeroth-playground/compiler/graph/module-graph.ts @@ -0,0 +1,5 @@ +import type { LinearGraph } from "./linear-graph"; +import type { SpnGraph } from "./spn-graph"; + +/** What a lowering produces: the graph of a linear theory, or of the SPN theory. */ +export type ModuleGraph = LinearGraph | SpnGraph; diff --git a/pocs/zeroth-playground/compiler/graph/spn-graph.ts b/pocs/zeroth-playground/compiler/graph/spn-graph.ts new file mode 100644 index 00000000..bad8e0a6 --- /dev/null +++ b/pocs/zeroth-playground/compiler/graph/spn-graph.ts @@ -0,0 +1,180 @@ +import type { NetItem } from "../ir/net-item"; + +/** + * The SPN module graph: what the clocks strategy lowers a stochastic net to + * before the Python is written. It follows Zeroth's SPN theory, after its + * own `birth_death.py`: a place is a Nat counter, a transition owns a Clock + * armed with an exponential delay and an Event it toggles when it fires, + * and every clock runs down against one external time reference. A + * transition in a conflict the options leave open also reads a Bool pick that + * nothing drives. + * + * The expressions are the forms the theory's sugar accepts: a count is + * tested against zero and stepped by one, a clock is tested against zero, + * kept non-negative and run at a constant rate against the time reference, + * and an event is read with `fired`. Awaits are implicit: `fired(ev)` awaits + * the event and `d(t)` awaits the time reference, so no reference is marked + * `next`. A variable that does not move flows `0`, and a module without a + * `flow` gets that zero flow for every variable it drives. No interpreter runs + * this graph: time is continuous. + */ + +export type SpnSort = "nat" | "clock" | "event" | "bool"; + +export type SpnVariable = { + name: string; + sort: SpnSort; + /** + * `time`: the external reference every clock runs against. `place`: a + * token count. `clock`: the time left until a transition fires, hidden in + * the system. `event`: what a transition toggles when it fires. `pick`: + * the environment lets a transition in a conflict fire when its clock + * expires; external, and driven by nothing. + */ + role: "time" | "place" | "clock" | "event" | "pick"; + comment?: string; +}; + +export type SpnExpr = + | { kind: "ref"; name: string } + | { kind: "nat"; value: number } + | { kind: "bool"; value: boolean } + /** `x == 0` when `zero`, `x != 0` otherwise: the one test on a count or a clock. */ + | { kind: "test"; operand: SpnExpr; zero: boolean } + | { kind: "logic"; op: "&"; left: SpnExpr; right: SpnExpr } + | { kind: "not"; operand: SpnExpr } + /** `n + 1` or `n - 1`: a count moves one token at a time. */ + | { kind: "step"; op: "+" | "-"; operand: SpnExpr } + | { + kind: "ite"; + condition: SpnExpr; + thenBranch: SpnExpr; + elseBranch: SpnExpr; + } + /** + * `ite(c, e, None)`, the partial if-then: a value where the condition holds, + * none otherwise. In `next` the variable stutters there; in `flow` time may + * not pass there. + */ + | { kind: "ifThen"; condition: SpnExpr; branch: SpnExpr } + /** `fired(ev)`: the event changes value this step. */ + | { kind: "fired"; event: string } + /** `exp(rate)`: a clock armed with an exponential delay. */ + | { kind: "exp"; rate: number } + /** `clk >= 0`: the invariant that keeps time from passing an expiry. */ + | { kind: "nonNegative"; clock: string } + /** `factor * d(t)`: a clock's flow against the time reference. */ + | { kind: "rate"; factor: number; time: string } + /** `0`: the flow of a variable that does not move, such as an event. */ + | { kind: "zeroFlow" }; + +export type SpnStatement = { + kind: "assign"; + target: string; + expr: SpnExpr; +}; + +export type SpnModule = { + className: string; + /** The identifier the instance is bound to when the system composes it. */ + instance: string; + /** The net item the module stands for: a place or a transition. */ + source: NetItem; + docstring: string; + /** Variables the module drives, in parameter order. */ + ctrl: string[]; + /** Variables the module reads but does not drive, in parameter order. */ + extl: string[]; + /** One initial value per `ctrl`. */ + init: SpnExpr[]; + next: SpnStatement[]; + /** One next value per `ctrl`, read after `next`'s statements. */ + returns: SpnExpr[]; + /** + * One tangent per `ctrl`, `zeroFlow()` for a variable that does not move. + * Absent on a module with no `flow` method, whose variables all flow `0`. + */ + flow?: SpnExpr[]; +}; + +export type SpnGraph = { + language: "spn"; + variables: SpnVariable[]; + /** The system composes every module, in this order. */ + modules: SpnModule[]; + /** Variables the system hides: driven by a module, absent from its interface. */ + hidden: string[]; +}; + +export function ref(name: string): SpnExpr { + return { kind: "ref", name }; +} + +export function nat(value: number): SpnExpr { + return { kind: "nat", value }; +} + +export function bool(value: boolean): SpnExpr { + return { kind: "bool", value }; +} + +export function isZero(operand: SpnExpr): SpnExpr { + return { kind: "test", operand, zero: true }; +} + +export function nonZero(operand: SpnExpr): SpnExpr { + return { kind: "test", operand, zero: false }; +} + +export function and(left: SpnExpr, right: SpnExpr): SpnExpr { + return { kind: "logic", op: "&", left, right }; +} + +export function not(operand: SpnExpr): SpnExpr { + return { kind: "not", operand }; +} + +export function inc(operand: SpnExpr): SpnExpr { + return { kind: "step", op: "+", operand }; +} + +export function dec(operand: SpnExpr): SpnExpr { + return { kind: "step", op: "-", operand }; +} + +export function ite(condition: SpnExpr, thenBranch: SpnExpr, elseBranch: SpnExpr): SpnExpr { + return { kind: "ite", condition, thenBranch, elseBranch }; +} + +export function ifThen(condition: SpnExpr, branch: SpnExpr): SpnExpr { + return { kind: "ifThen", condition, branch }; +} + +export function fired(event: string): SpnExpr { + return { kind: "fired", event }; +} + +export function exp(rate: number): SpnExpr { + return { kind: "exp", rate }; +} + +export function nonNegative(clock: string): SpnExpr { + return { kind: "nonNegative", clock }; +} + +export function rate(factor: number, time: string): SpnExpr { + return { kind: "rate", factor, time }; +} + +export function zeroFlow(): SpnExpr { + return { kind: "zeroFlow" }; +} + +/** The terms joined with `&`, left to right; `null` for no terms. */ +export function conjunction(terms: SpnExpr[]): SpnExpr | null { + return terms.reduce((all, term) => (all === null ? term : and(all, term)), null); +} + +export function assign(target: string, expr: SpnExpr): SpnStatement { + return { kind: "assign", target, expr }; +} diff --git a/pocs/zeroth-playground/compiler/index.ts b/pocs/zeroth-playground/compiler/index.ts new file mode 100644 index 00000000..bfaa5a72 --- /dev/null +++ b/pocs/zeroth-playground/compiler/index.ts @@ -0,0 +1,61 @@ +/** + * The compiler's public API: the only module the playground and the examples + * import. Everything else in compiler/ is internal. + */ + +// The pipeline +export { compile } from "./compile"; +export type { Compilation, CompileSettings } from "./compile"; +export type { CompiledFile } from "./emit/python"; + +// The IR +export { petriNetIrSchema } from "./ir/schema"; +export type { + PetriNetIr, + PetriNetIrArc, + PetriNetIrColour, + PetriNetIrPlace, + PetriNetIrToken, + PetriNetIrTransition, +} from "./ir/schema"; +export { parsePetriNetIr } from "./ir/parse"; +export { arcKind, arcWeight, conflictingTransitions, initialTokens } from "./ir/accessors"; +export { yamlKeys } from "./ir/yaml-keys"; +export type { YamlKey } from "./ir/yaml-keys"; +export { itemLabel, sameItem } from "./ir/net-item"; +export type { NetItem } from "./ir/net-item"; + +// Options +export { OPTIONS, OPTION_NAMES, optionStates, optionsForNet, resolveOptions } from "./options"; +export type { + CompilerOptions, + NetFacts, + OptionName, + OptionSpec, + OptionState, + ResolvedOptions, +} from "./options"; + +// Diagnostics +export { DIAGNOSTIC_CODES } from "./diagnostics"; +export type { Diagnostic, DiagnosticCode } from "./diagnostics"; + +// Traces +export { linesOfItem, matchingLines, provenanceAt } from "./trace/provenance"; +export type { LineRange, Provenance, Trace, TraceRange } from "./trace/provenance"; + +// The module graph, for readers such as the Compiler view +export type { ModuleGraph } from "./graph/module-graph"; +export type { + LinearExpr, + LinearGraph, + LinearModule, + LinearVariable, + Theory, +} from "./graph/linear-graph"; +export type { SpnExpr, SpnGraph, SpnModule, SpnVariable } from "./graph/spn-graph"; + +// The code-parser hook +export type { CodeExpr, CodeFunction, CodeParser, CodeSurface } from "./code/code-tree"; +export { codeStrings } from "./code/code-strings"; +export type { CodeString } from "./code/code-strings"; diff --git a/pocs/zeroth-playground/compiler/ir/accessors.test.ts b/pocs/zeroth-playground/compiler/ir/accessors.test.ts new file mode 100644 index 00000000..570d3eb8 --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/accessors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { arcWeight, conflictingTransitions, initialTokens } from "./accessors"; + +import type { PetriNetIr } from "./schema"; + +describe("conflictingTransitions", () => { + it("names the transitions that share an input place, read arcs included, in record order", () => { + // GIVEN two transitions sharing Pool, one through a read arc, and two that share nothing + const sharing: PetriNetIr["transitions"] = { + Lone: { inputs: { A: null } }, + Right: { inputs: { Pool: null, B: null } }, + Left: { inputs: { Pool: { kind: "read" } } }, + Source: { outputs: { Pool: null } }, + }; + const apart: PetriNetIr["transitions"] = { + Go: { inputs: { A: null }, outputs: { B: null } }, + Back: { inputs: { B: null }, outputs: { A: null } }, + }; + // WHEN the conflicts are found + const shared = conflictingTransitions(sharing); + const none = conflictingTransitions(apart); + // THEN only the transitions sharing an input place are named + expect([...shared]).toEqual(["Right", "Left"]); + expect(none.size).toBe(0); + }); +}); + +describe("IR accessors", () => { + it("read the defaults behind bare keys", () => { + // GIVEN bare and explicit arcs and markings + // THEN the accessors read the defaults the bare keys stand for + expect(arcWeight(null)).toBe(1); + expect(arcWeight({ weight: 3 })).toBe(3); + expect(initialTokens({}, "A")).toBe(0); + expect(initialTokens({ marking: { A: 4 } }, "A")).toBe(4); + expect(initialTokens({ marking: { A: 4 } }, "B")).toBe(0); + expect(initialTokens({ marking: { A: [{ x: 1 }, { x: 2 }] } }, "A")).toBe(2); + }); +}); diff --git a/pocs/zeroth-playground/compiler/ir/accessors.ts b/pocs/zeroth-playground/compiler/ir/accessors.ts new file mode 100644 index 00000000..20a10684 --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/accessors.ts @@ -0,0 +1,34 @@ +import type { PetriNetIr, PetriNetIrArc, PetriNetIrTransition } from "./schema"; + +/** The defaults behind the IR's bare keys, read in one place. */ + +export function arcWeight(arc: PetriNetIrArc): number { + return arc?.weight ?? 1; +} + +export function arcKind(arc: PetriNetIrArc): "standard" | "read" | "inhibitor" { + return arc?.kind ?? "standard"; +} + +/** The tokens a place starts with: its count, or the number of its records. */ +export function initialTokens(ir: Pick, place: string): number { + const marking = ir.marking?.[place]; + return marking === undefined ? 0 : typeof marking === "number" ? marking : marking.length; +} + +/** + * The transitions in a conflict: each shares an input place with another + * transition, whatever the arcs' kinds. In record order. + */ +export function conflictingTransitions( + transitions: Record, +): Set { + const readers = new Map(); + for (const [name, transition] of Object.entries(transitions)) { + for (const place of Object.keys(transition.inputs ?? {})) { + readers.set(place, [...(readers.get(place) ?? []), name]); + } + } + const shared = new Set([...readers.values()].filter((names) => names.length > 1).flat()); + return new Set(Object.keys(transitions).filter((name) => shared.has(name))); +} diff --git a/pocs/zeroth-playground/compiler/ir/net-item.ts b/pocs/zeroth-playground/compiler/ir/net-item.ts new file mode 100644 index 00000000..695703ac --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/net-item.ts @@ -0,0 +1,42 @@ +/** + * One item of a net, named as the IR names it: what a diagnostic is about, + * and what a traced line comes from. + */ +export type NetItem = { + kind: "net" | "place" | "transition" | "colour" | "dynamics"; + /** The IR name; the net's own name for the net. */ + name: string; +}; + +/** The net itself, by its name. */ +export function netItem(name: string): NetItem { + return { kind: "net", name }; +} + +/** The item the first two keys of an IR path name: `places.Pool` is the place Pool. */ +export function itemAtPath(path: readonly PropertyKey[]): NetItem { + const [section, entry] = path; + const name = typeof entry === "string" ? entry : ""; + switch (section) { + case "places": + case "marking": + return { kind: "place", name }; + case "transitions": + return { kind: "transition", name }; + case "colours": + return { kind: "colour", name }; + case "dynamics": + return { kind: "dynamics", name }; + default: + return netItem(typeof section === "string" ? section : ""); + } +} + +export function sameItem(a: NetItem, b: NetItem): boolean { + return a.kind === b.kind && a.name === b.name; +} + +/** The item as a reader names it: `place Pool`. */ +export function itemLabel(item: NetItem): string { + return `${item.kind} ${item.name}`; +} diff --git a/pocs/zeroth-playground/compiler/ir/parse.test.ts b/pocs/zeroth-playground/compiler/ir/parse.test.ts new file mode 100644 index 00000000..05477504 --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/parse.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; + +import { parsePetriNetIr } from "./parse"; + +import type { PetriNetIr } from "./schema"; + +describe("parsePetriNetIr", () => { + it("reads colours, dynamics, code blocks, token lists and quoted keys", () => { + // GIVEN a coloured net written as block YAML, with a key YAML 1.1 reads as a Boolean + const text = [ + "name: boiler", + "kind: plain", + "", + "colours:", + " Vessel:", + " level: real", + " state:", + " enum: [idle, hot]", + "", + "dynamics:", + " Heat:", + " colour: Vessel", + " code: |", + " return tokens.map((t) => ({ level: 0.5 * (10 - t.level) }));", + "", + "places:", + " Tank:", + " colour: Vessel", + " dynamics: Heat", + " capacity: 1", + " 'On':", + "", + "marking:", + " Tank:", + " - {level: 0, state: idle}", + "", + "transitions:", + " Alarm:", + " inputs:", + " Tank:", + " kind: read", + " outputs:", + " 'On':", + " weight: 2", + " guard: |", + " return input.Tank[0].level >= 8;", + "", + ].join("\n"); + // WHEN it is parsed + const parsed = parsePetriNetIr(text); + // THEN every section reads as the IR, code blocks keep their newline, and On is a name + const expected: PetriNetIr = { + name: "boiler", + kind: "plain", + colours: { Vessel: { level: "real", state: { enum: ["idle", "hot"] } } }, + dynamics: { + Heat: { + colour: "Vessel", + code: "return tokens.map((t) => ({ level: 0.5 * (10 - t.level) }));\n", + }, + }, + places: { Tank: { colour: "Vessel", dynamics: "Heat", capacity: 1 }, On: null }, + marking: { Tank: [{ level: 0, state: "idle" }] }, + transitions: { + Alarm: { + inputs: { Tank: { kind: "read" } }, + outputs: { On: { weight: 2 } }, + guard: "return input.Tank[0].level >= 8;\n", + }, + }, + }; + expect(parsed).toEqual({ ok: true, ir: expected }); + }); + + it("reports a YAML syntax error at its line", () => { + // GIVEN a document whose last key is under-indented + const text = "name: cycle\nkind: plain\nplaces:\n A:\n B:\n"; + // WHEN it is parsed + const parsed = parsePetriNetIr(text); + // THEN one yaml-syntax error points at that line + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.errors).toHaveLength(1); + expect(parsed.errors[0]).toMatchObject({ code: "yaml-syntax", line: 5 }); + expect(parsed.errors[0]?.message).toContain("indentation"); + } + }); + + it("reports every schema issue at the line of its key, naming the item", () => { + // GIVEN a document with a bad kind, a negative capacity, a lowercase name and a misspelt key + const text = [ + "name: cycle", + "kind: weird", + "", + "places:", + " A:", + " capacity: -1", + " lower:", + "", + "transitions:", + " Go:", + " input:", + " A:", + " rate: 2", + ].join("\n"); + // WHEN it is parsed + const parsed = parsePetriNetIr(text); + // THEN each issue is one error at its key's line, naming the item it concerns + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.errors.map((error) => [error.item.kind, error.item.name, error.line])).toEqual([ + ["net", "kind", 2], + ["place", "A", 6], + ["place", "lower", 7], + ["transition", "Go", 10], + ]); + expect(parsed.errors.map((error) => error.message)).toEqual([ + expect.stringContaining("kind: "), + expect.stringContaining("places.A.capacity: "), + expect.stringContaining("UpperCamelCase"), + expect.stringContaining('transitions.Go: Unrecognized key: "input"'), + ]); + } + }); + + it("reports a zeroth section as an unknown key: the options are passed beside the document", () => { + // GIVEN a plain net that also writes compiler options + const text = [ + "name: cycle", + "kind: plain", + "places:", + " A:", + "transitions: {}", + "zeroth:", + " shape: modular", + ].join("\n"); + // WHEN it is parsed + const parsed = parsePetriNetIr(text); + // THEN the section is refused as a key the IR does not have + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.errors.map((error) => [error.code, error.message])).toEqual([ + ["schema", 'Unrecognized key: "zeroth"'], + ]); + } + }); + + it("reports an empty document as a YAML error with no line", () => { + // GIVEN no text, blank lines, and a comment alone + const texts = ["", " \n\n", "# only a comment\n"]; + // WHEN each is parsed + const outcomes = texts.map((text) => parsePetriNetIr(text)); + // THEN each is one yaml-syntax error that points at no line + for (const parsed of outcomes) { + expect(parsed).toEqual({ + ok: false, + errors: [ + { + code: "yaml-syntax", + message: "expected a document, but the input is empty", + item: { kind: "net", name: "document" }, + }, + ], + }); + } + }); +}); diff --git a/pocs/zeroth-playground/compiler/ir/parse.ts b/pocs/zeroth-playground/compiler/ir/parse.ts new file mode 100644 index 00000000..5817db2e --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/parse.ts @@ -0,0 +1,73 @@ +import { YAMLException, load } from "js-yaml"; +import type { z } from "zod"; + +import { isDiagnosticCode } from "../diagnostics"; +import { itemAtPath, netItem } from "./net-item"; +import { petriNetIrSchema } from "./schema"; +import { yamlKeys } from "./yaml-keys"; + +import type { Diagnostic, DiagnosticCode } from "../diagnostics"; +import type { PetriNetIr } from "./schema"; + +export type ParseOutcome = { ok: true; ir: PetriNetIr } | { ok: false; errors: Diagnostic[] }; + +/** The code a reference check gives its issue, or `schema` for a shape issue. */ +function issueCode(issue: z.core.$ZodIssue): DiagnosticCode { + return issue.code === "custom" && isDiagnosticCode(issue.params?.code) + ? issue.params.code + : "schema"; +} + +/** A record key that fails its pattern is reported through the key's own message. */ +function issueMessage(issue: z.core.$ZodIssue): string { + return issue.code === "invalid_key" && issue.issues.length > 0 + ? issue.issues.map((inner) => inner.message).join("; ") + : issue.message; +} + +function yamlError(error: YAMLException): Diagnostic { + return { + code: "yaml-syntax", + message: error.reason, + item: netItem("document"), + ...(error.mark === undefined ? {} : { line: error.mark.line + 1 }), + }; +} + +/** + * Reads an IR document: YAML first, then the schema, then the references + * between its sections. Every issue is one diagnostic at the line of the key + * it concerns, so a reader fixes them all in one pass. The references are + * checked once the shape is right. + */ +export function parsePetriNetIr(text: string): ParseOutcome { + let loaded: unknown; + try { + loaded = load(text); + } catch (error) { + if (error instanceof YAMLException) { + return { ok: false, errors: [yamlError(error)] }; + } + throw error; + } + const result = petriNetIrSchema.safeParse(loaded); + if (result.success) { + return { ok: true, ir: result.data }; + } + // A path met twice, such as a key in each item of a list, takes its last line. + const lines = new Map(yamlKeys(text).map((key) => [key.path.join("."), key.line])); + return { + ok: false, + errors: result.error.issues.map((issue) => { + const path = issue.path.map(String).join("."); + const line = lines.get(path); + const message = issueMessage(issue); + return { + code: issueCode(issue), + message: path === "" ? message : `${path}: ${message}`, + item: itemAtPath(issue.path), + ...(line === undefined ? {} : { line }), + }; + }), + }; +} diff --git a/pocs/zeroth-playground/compiler/ir/references.test.ts b/pocs/zeroth-playground/compiler/ir/references.test.ts new file mode 100644 index 00000000..ef22afeb --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/references.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; + +import { parsePetriNetIr } from "./parse"; + +/** Each error of a document as its code, item and line; empty when it parses. */ +function issues(lines: readonly string[]) { + const parsed = parsePetriNetIr(lines.join("\n")); + return parsed.ok + ? [] + : parsed.errors.map(({ code, item, line }) => [code, `${item.kind} ${item.name}`, line]); +} + +/** A net of the given kind with one transition per rate, each taking from A; an empty rate is none. */ +function netOfKind(kind: string, rates: readonly string[]): string[] { + return [ + "name: cycle", + `kind: ${kind}`, + "places:", + " A:", + "transitions:", + ...rates.flatMap((rate, index) => [ + ` T${index}:`, + " inputs:", + " A:", + ...(rate === "" ? [] : [` rate: ${rate}`]), + ]), + ]; +} + +describe("the references between the sections of a document", () => { + it("reports an arc and a marking entry that name no declared place", () => { + // GIVEN a cycle whose arc and marking name a place C that is not declared + const lines = [ + "name: cycle", + "kind: plain", + "places:", + " A:", + " B:", + "marking:", + " C: 1", + "transitions:", + " Go:", + " inputs:", + " A:", + " outputs:", + " C:", + ]; + // WHEN it is parsed + const found = issues(lines); + // THEN each reference is one error at its own key + expect(found).toEqual([ + ["unknown-place", "transition Go", 13], + ["unknown-place", "place C", 7], + ]); + }); + + it("reports a colour and dynamics that are not declared", () => { + // GIVEN a place of an undeclared colour, a place with undeclared dynamics, and dynamics of an undeclared colour + const lines = [ + "name: tank", + "kind: plain", + "colours:", + " Water:", + " level: real", + "dynamics:", + " Drain:", + " colour: Oil", + " code: return tokens;", + "places:", + " Tank:", + " colour: Mud", + " Sump:", + " colour: Water", + " dynamics: Fill", + "transitions: {}", + ]; + // WHEN it is parsed + const found = issues(lines); + // THEN each name is one error on the item that uses it + expect(found).toEqual([ + ["unknown-colour", "place Tank", 12], + ["unknown-dynamics", "place Sump", 15], + ["unknown-colour", "dynamics Drain", 8], + ]); + }); + + it("reports a name the generated Python uses, and a net whose class would take one", () => { + // GIVEN a net named event, with a place Event and a transition Module + const lines = [ + "name: event", + "kind: plain", + "places:", + " Event:", + " Pool:", + "transitions:", + " Module:", + " inputs:", + " Pool:", + ]; + // WHEN it is parsed + const found = issues(lines); + // THEN the place, the transition and the net's name are each reserved + expect(found).toEqual([ + ["reserved-name", "place Event", 4], + ["reserved-name", "transition Module", 7], + ["reserved-name", "net name", 1], + ]); + }); + + it("reports a marking that is not a whole count on a plain place or a token list on a coloured one", () => { + // GIVEN plain places starting at -1, at 1.5 and with a token list, and a coloured place starting at a count + const lines = [ + "name: bad_marking", + "kind: plain", + "colours:", + " Ball:", + " x: real", + "places:", + " Debt:", + " Half:", + " Listed:", + " Balls:", + " colour: Ball", + "marking:", + " Debt: -1", + " Half: 1.5", + " Listed:", + " - {x: 1}", + " Balls: 2", + "transitions: {}", + ]; + // WHEN it is parsed + const found = issues(lines); + // THEN each place's marking is invalid + expect(found).toEqual([ + ["marking-invalid", "place Debt", 13], + ["marking-invalid", "place Half", 14], + ["marking-invalid", "place Listed", 15], + ["marking-invalid", "place Balls", 17], + ]); + }); + + it("reports a marking over the place's capacity, as a count or as tokens", () => { + // GIVEN a plain place of capacity 2 starting with 3 tokens, and a coloured one of capacity 1 starting with 2 + const lines = [ + "name: full", + "kind: plain", + "colours:", + " Ball:", + " x: real", + "places:", + " Pool:", + " capacity: 2", + " Bag:", + " colour: Ball", + " capacity: 1", + "marking:", + " Pool: 3", + " Bag:", + " - {x: 1}", + " - {x: 2}", + "transitions: {}", + ]; + // WHEN it is parsed + const found = issues(lines); + // THEN both are over capacity + expect(found).toEqual([ + ["marking-over-capacity", "place Pool", 13], + ["marking-over-capacity", "place Bag", 14], + ]); + }); + + it("reports a kind the rates do not give", () => { + // GIVEN a net called stochastic with one rated and one unrated transition, and one called plain with a rate + // WHEN each is parsed, beside the nets whose kind matches + const found = [ + issues(netOfKind("stochastic", ["1", ""])), + issues(netOfKind("plain", ["1"])), + issues(netOfKind("mixed", ["1", "1"])), + issues(netOfKind("mixed", ["1", ""])), + issues(netOfKind("stochastic", ["1", "return 2;"])), + issues(netOfKind("plain", [""])), + ]; + // THEN only the three that disagree are reported, at the kind + expect(found).toEqual([ + [["kind-mismatch", "net kind", 2]], + [["kind-mismatch", "net kind", 2]], + [["kind-mismatch", "net kind", 2]], + [], + [], + [], + ]); + }); + + it("checks the references only once the shape is right", () => { + // GIVEN a document with a misspelt key and an arc to an undeclared place + const lines = [ + "name: cycle", + "kind: plain", + "places:", + " A:", + "transitions:", + " Go:", + " inputs:", + " C:", + " output:", + " A:", + ]; + // WHEN it is parsed + const found = issues(lines); + // THEN only the shape is reported + expect(found).toEqual([["schema", "transition Go", 6]]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/ir/references.ts b/pocs/zeroth-playground/compiler/ir/references.ts new file mode 100644 index 00000000..717c0165 --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/references.ts @@ -0,0 +1,150 @@ +import { netClassName, RESERVED_MODULE_NAMES } from "./reserved-names"; + +import type { z } from "zod"; +import type { DiagnosticCode } from "../diagnostics"; +import type { PetriNetIr } from "./schema"; + +/** + * What the shape of the document cannot say on its own: every name it uses + * is declared, no name is one the generated Python claims, the marking + * fits its places and `kind` agrees with the rates. Each issue carries its + * diagnostic code in `params.code`. + */ + +type Issue = { code: DiagnosticCode; message: string; path: PropertyKey[] }; + +const SECTIONS = ["places", "transitions", "colours", "dynamics"] as const; + +/** The kind the rates give the net: no rates, a rate on every transition, or some. */ +function kindOfRates(ir: PetriNetIr): PetriNetIr["kind"] { + const transitions = Object.values(ir.transitions); + const rated = transitions.filter((transition) => transition.rate !== undefined).length; + return rated === 0 ? "plain" : rated === transitions.length ? "stochastic" : "mixed"; +} + +function unknownNames(ir: PetriNetIr): Issue[] { + const issues: Issue[] = []; + function place(name: string, path: PropertyKey[]): void { + if (!(name in ir.places)) { + issues.push({ code: "unknown-place", message: `no place ${name} is declared`, path }); + } + } + function colour(name: string, path: PropertyKey[]): void { + if (ir.colours?.[name] === undefined) { + issues.push({ code: "unknown-colour", message: `no colour ${name} is declared`, path }); + } + } + for (const [name, entry] of Object.entries(ir.places)) { + if (entry?.colour !== undefined) { + colour(entry.colour, ["places", name, "colour"]); + } + if (entry?.dynamics !== undefined && ir.dynamics?.[entry.dynamics] === undefined) { + issues.push({ + code: "unknown-dynamics", + message: `no dynamics ${entry.dynamics} is declared`, + path: ["places", name, "dynamics"], + }); + } + } + for (const [name, equation] of Object.entries(ir.dynamics ?? {})) { + colour(equation.colour, ["dynamics", name, "colour"]); + } + for (const [name, transition] of Object.entries(ir.transitions)) { + for (const side of ["inputs", "outputs"] as const) { + for (const arcPlace of Object.keys(transition[side] ?? {})) { + place(arcPlace, ["transitions", name, side, arcPlace]); + } + } + } + for (const marked of Object.keys(ir.marking ?? {})) { + place(marked, ["marking", marked]); + } + return issues; +} + +function reservedNames(ir: PetriNetIr): Issue[] { + const issues: Issue[] = SECTIONS.flatMap((section) => + Object.keys(ir[section] ?? {}) + .filter((name) => RESERVED_MODULE_NAMES.includes(name)) + .map((name) => ({ + code: "reserved-name", + message: `${name} is a name the generated Python uses`, + path: [section, name], + })), + ); + const className = netClassName(ir.name); + if (RESERVED_MODULE_NAMES.includes(className)) { + issues.push({ + code: "reserved-name", + message: `the net's module class would be ${className}, a name the generated Python uses`, + path: ["name"], + }); + } + return issues; +} + +function markingIssues(ir: PetriNetIr): Issue[] { + return Object.entries(ir.marking ?? {}).flatMap(([name, marking]): Issue[] => { + if (!(name in ir.places)) { + return []; + } + const place = ir.places[name]; + const path = ["marking", name]; + if (place?.colour !== undefined && !Array.isArray(marking)) { + return [ + { + code: "marking-invalid", + message: `a coloured place starts with a list of tokens; the marking is ${marking}`, + path, + }, + ]; + } + if ( + place?.colour === undefined && + !(typeof marking === "number" && Number.isInteger(marking) && marking >= 0) + ) { + return [ + { + code: "marking-invalid", + message: `a plain place starts with a whole, non-negative count; the marking is ${Array.isArray(marking) ? "a list of tokens" : marking}`, + path, + }, + ]; + } + const tokens = Array.isArray(marking) ? marking.length : marking; + return place?.capacity !== undefined && tokens > place.capacity + ? [ + { + code: "marking-over-capacity", + message: `the place starts with ${tokens} tokens and holds at most ${place.capacity}`, + path, + }, + ] + : []; + }); +} + +function kindIssues(ir: PetriNetIr): Issue[] { + const fromRates = kindOfRates(ir); + return fromRates === ir.kind + ? [] + : [ + { + code: "kind-mismatch", + message: `the net is ${ir.kind}, but its rates make it ${fromRates}: plain has no rates, stochastic a rate on every transition, mixed a rate on some`, + path: ["kind"], + }, + ]; +} + +/** The schema's last check, over a document whose shape is already right. */ +export function checkReferences(ir: PetriNetIr, context: z.RefinementCtx): void { + for (const { code, message, path } of [ + ...unknownNames(ir), + ...reservedNames(ir), + ...markingIssues(ir), + ...kindIssues(ir), + ]) { + context.addIssue({ code: "custom", message, path, params: { code } }); + } +} diff --git a/pocs/zeroth-playground/compiler/ir/reserved-names.ts b/pocs/zeroth-playground/compiler/ir/reserved-names.ts new file mode 100644 index 00000000..13fed6da --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/reserved-names.ts @@ -0,0 +1,43 @@ +/** + * Names the generated Python uses for itself; the IR must not claim them. + * IR names are UpperCamelCase, so only the capitalised identifiers can + * collide; the lowercase ones are listed for the reader. + */ +export const RESERVED_MODULE_NAMES: readonly string[] = [ + "net", + "ite", + "X", + "Module", + "Var", + "LIA", + "LRA", + "Int", + "Real", + "Bool", + "INT", + "REAL", + "BOOL", + "SPN", + "Nat", + "Clock", + "Event", + "t", + "d", + "exp", + "fired", + "self", + "None", + "True", + "False", +]; + +/** The class the net's module is named after: `birth_death` becomes `BirthDeath`, and an empty name `Net`. */ +export function netClassName(netName: string): string { + return ( + netName + .split("_") + .filter((part) => part !== "") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("") || "Net" + ); +} diff --git a/pocs/zeroth-playground/compiler/ir/schema.ts b/pocs/zeroth-playground/compiler/ir/schema.ts new file mode 100644 index 00000000..95143276 --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/schema.ts @@ -0,0 +1,176 @@ +import { z } from "zod"; + +import { checkReferences } from "./references"; + +/** + * The Petri net IR: one net as plain data, with places, weighted arcs, the + * initial marking, the firing rate or guard of each transition and, for a + * coloured net, the token colours, the kernels that write tokens and the + * dynamics that move them between steps. Code appears only as bare-body + * TypeScript with the net's parameters inlined, so the document carries no + * parameters and no layout. The compiler options are not part of it: they + * are passed beside it. + * + * Places, transitions, colours, dynamics, the arcs of a transition and the + * marking are records keyed by name. A name is UpperCamelCase, so it is + * unique within its record and usable as a variable name in generated code. + * A field at its default is left out, and an entry with every field at its + * default is `null`, written as a bare key. Objects are strict, so a + * misspelt key is reported where it stands instead of being read as a + * default. + * + * This schema is the IR's only definition: the types are inferred from it. + * `references.ts` adds the checks across sections: names declared, names + * the Python claims, the marking against its places, `kind` against the + * rates. + */ + +/** A place, transition, colour, dynamics or arc key. */ +export const PETRI_NET_IR_NAME_PATTERN = /^[A-Z][A-Za-z0-9]*$/u; + +/** The net's own name, which the compiler derives a module name from. */ +export const PETRI_NET_IR_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u; + +const name = z + .string() + .regex(PETRI_NET_IR_NAME_PATTERN, "a name is UpperCamelCase: Pool, TakeLeft"); + +/** + * Bare-body TypeScript for one code surface: the statements alone, ending + * in `return`, with the input object ambient (`input` for a guard, rate or + * kernel, `tokens` for dynamics) and the net's parameters inlined. + */ +const code = z.string(); + +/** + * A token attribute's type. A string attribute the net only ever writes + * from a finite set of literals is closed to that set as `{ enum }`, in + * first-seen order, so a compiler can number the values. + */ +const attribute = z.union([ + z.enum(["real", "integer", "boolean", "uuid", "string"]), + z.strictObject({ enum: z.array(z.string()) }), +]); + +/** A colour: its attributes keyed by name, in declaration order. */ +const colour = z.record(z.string(), attribute); + +/** A differential equation shared by the places that name it. */ +const dynamics = z.strictObject({ + /** The colour whose tokens it moves. */ + colour: z.string(), + /** + * The `dynamics` surface: `return tokens.map((token) => ({ attribute: + * derivative, ... }));`, one derivative per real attribute it moves. + */ + code, +}); + +/** + * One arc keyed by its place; `null` is a standard arc of weight one. A + * `read` arc needs the place to hold the weight and leaves it alone; an + * `inhibitor` arc needs the place to hold fewer. Input arcs keep the order + * the model gives them, which is the order token bindings are enumerated in. + */ +const arc = z + .strictObject({ + /** Tokens the arc moves or tests. Absent means one. */ + weight: z.number().int().min(1).optional(), + /** Absent means a standard arc, which moves its tokens. */ + kind: z.enum(["read", "inhibitor"]).optional(), + }) + .nullable(); + +const arcs = z.record(name, arc); + +/** A place; `null` holds any number of plain tokens. */ +const place = z + .strictObject({ + /** Maximum tokens the place holds. Absent means unbounded. */ + capacity: z.number().int().min(0).optional(), + /** The colour of the tokens it holds. Absent means plain counting tokens. */ + colour: z.string().optional(), + /** The `dynamics` entry that moves its tokens between steps. */ + dynamics: z.string().optional(), + }) + .nullable(); + +/** One token of a coloured place, its attributes keyed by name. */ +const token = z.record(z.string(), z.union([z.number(), z.boolean(), z.string()])); + +/** + * Tokens each place starts with, keyed by place: a count for a plain + * place, one record per token for a coloured one. A place absent here + * starts empty. + */ +const marking = z.record(name, z.union([z.number(), z.array(token)])); + +const transition = z.strictObject({ + /** Input arcs keyed by place, in binding order. Absent means none. */ + inputs: arcs.optional(), + /** Output arcs keyed by place: tokens the firing produces. Absent means none. */ + outputs: arcs.optional(), + /** + * A predicate transition's condition over its input tokens, a `lambda` + * surface returning a boolean. Absent means the transition fires whenever + * its arcs allow it. + */ + guard: code.optional(), + /** + * A stochastic transition's mean firings per time unit while enabled: a + * constant, or a `lambda` surface returning it from the input tokens. + */ + rate: z.union([z.number(), code]).optional(), + /** + * The `kernel` surface that writes the produced tokens' attributes. + * Present when an output place is coloured. + */ + kernel: code.optional(), + /** + * The model marks the firing as a choice a controller makes. Absent means + * the transition fires whenever it is enabled. + */ + controllable: z.literal(true).optional(), +}); + +const net = z.strictObject({ + /** The net's own name, an identifier the module's class is named after. */ + name: z.string().regex(PETRI_NET_IR_IDENTIFIER_PATTERN, "the net's name is an identifier"), + /** Prose about the net, for a reader. */ + description: z.string().optional(), + /** + * Whether every transition fires when enabled (`plain`), every transition + * fires at a rate (`stochastic`), or the net has both (`mixed`). A + * transition with a `rate` is stochastic; one without fires when its + * guard holds. + */ + kind: z.enum(["plain", "stochastic", "mixed"]), + /** Token colours keyed by name. Absent when every place is plain. */ + colours: z.record(name, colour).optional(), + /** Differential equations keyed by name. Absent when no place has dynamics. */ + dynamics: z.record(name, dynamics).optional(), + places: z.record(name, place), + /** The initial marking. Absent when every place starts empty. */ + marking: marking.optional(), + /** Record order is the order a step sweeps the transitions in. */ + transitions: z.record(name, transition), +}); + +/** + * The document: its shape, then the references between its parts, checked + * once the shape is right. + */ +export const petriNetIrSchema = net.superRefine(checkReferences, { + // An unknown key does not abort the parse, so the default would check the + // references of a document whose shape is wrong. + when: (payload) => payload.issues.length === 0, +}); + +export type PetriNetIr = z.infer; +export type PetriNetIrPlace = z.infer; +export type PetriNetIrTransition = z.infer; +export type PetriNetIrArc = z.infer; +export type PetriNetIrArcs = z.infer; +export type PetriNetIrToken = z.infer; +export type PetriNetIrColour = z.infer; +export type PetriNetIrAttribute = z.infer; diff --git a/pocs/zeroth-playground/compiler/ir/yaml-keys.test.ts b/pocs/zeroth-playground/compiler/ir/yaml-keys.test.ts new file mode 100644 index 00000000..d88df813 --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/yaml-keys.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { yamlKeys } from "./yaml-keys"; + +describe("yamlKeys", () => { + it("gives every key its path and the lines it spans, and skips block scalar bodies", () => { + // GIVEN block YAML with a block scalar, blank lines between sections and a token list + const text = [ + "name: boiler", + "kind: plain", + "", + "dynamics:", + " Heat:", + " colour: Vessel", + " code: |", + " return tokens.map((t) => ({ level: 0.5 * (10 - t.level) }));", + "", + "places:", + " Tank:", + " capacity: 1", + " Alarms:", + "", + "marking:", + " Tank:", + " - {level: 0}", + ].join("\n"); + // WHEN its keys are read + const keys = yamlKeys(text); + // THEN each key runs to its last line, less trailing blanks, and the code is not a key + expect(keys.map(({ path, line, endLine }) => [path.join("."), line, endLine])).toEqual([ + ["name", 1, 1], + ["kind", 2, 2], + ["dynamics", 4, 8], + ["dynamics.Heat", 5, 8], + ["dynamics.Heat.colour", 6, 6], + ["dynamics.Heat.code", 7, 8], + ["places", 10, 13], + ["places.Tank", 11, 12], + ["places.Tank.capacity", 12, 12], + ["places.Alarms", 13, 13], + ["marking", 15, 17], + ["marking.Tank", 16, 17], + ]); + }); + + it("reads a quoted key by its name", () => { + // GIVEN a place a dumper quotes, since YAML 1.1 reads On as a Boolean + const text = "places:\n 'On':\n \"Off\":\n"; + // WHEN its keys are read + const keys = yamlKeys(text); + // THEN the paths hold the bare names + expect(keys.map((key) => key.path.join("."))).toEqual(["places", "places.On", "places.Off"]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/ir/yaml-keys.ts b/pocs/zeroth-playground/compiler/ir/yaml-keys.ts new file mode 100644 index 00000000..08894b0e --- /dev/null +++ b/pocs/zeroth-playground/compiler/ir/yaml-keys.ts @@ -0,0 +1,66 @@ +/** + * Every key of a block-style YAML text, with its path and the lines it + * spans, read from indentation alone. A key's range runs from its own line + * to the line before the next key at its indent or less, less the blank + * lines at its end. The lines under a block scalar are text, however much + * they look like keys. + */ + +export type YamlKey = { + /** `["transitions", "Go", "inputs", "A"]` for the line `A:` under Go's `inputs:`. */ + path: string[]; + /** 1-based. */ + line: number; + /** The last line of the key's value: 1-based, inclusive. */ + endLine: number; +}; + +// A key is bare, or quoted where YAML would read the bare word as another +// value: a dumper quotes `On`, `Off`, `Yes`, `No`, `True`, `Null` and the like. +const KEY_LINE = /^( *)(?:- )?(?:'([^']*)'|"([^"]*)"|([A-Za-z_$][\w-]*)):(?: (.*)|$)/u; +const BLOCK_SCALAR = /^[|>][-+]?$/u; + +/** The keys in line order. */ +export function yamlKeys(text: string): YamlKey[] { + const lines = text.split("\n"); + const keys: YamlKey[] = []; + // The keys still open, innermost last; a key at their indent or less closes them. + const open: { indent: number; key: YamlKey }[] = []; + function close(indent: number, before: number): void { + for (let top = open.at(-1); top !== undefined && top.indent >= indent; top = open.at(-1)) { + open.pop(); + let end = before - 1; + while (end > top.key.line && lines[end - 1]?.trim() === "") { + end -= 1; + } + top.key.endLine = end; + } + } + // The indent of a key whose value is a block scalar, while its lines run. + let blockIndent: number | null = null; + lines.forEach((line, index) => { + const number = index + 1; + if (blockIndent !== null) { + const indent = /^ */u.exec(line)?.[0].length ?? 0; + if (line.trim() === "" || indent > blockIndent) { + return; + } + blockIndent = null; + } + const match = KEY_LINE.exec(line); + if (match === null) { + return; + } + const indent = match[1]?.length ?? 0; + if (BLOCK_SCALAR.test(match[5] ?? "")) { + blockIndent = indent; + } + close(indent, number); + const name = match[2] ?? match[3] ?? match[4] ?? ""; + const key = { path: [...(open.at(-1)?.key.path ?? []), name], line: number, endLine: number }; + keys.push(key); + open.push({ indent, key }); + }); + close(-1, lines.length + 1); + return keys; +} diff --git a/pocs/zeroth-playground/compiler/lower/clocks.test.ts b/pocs/zeroth-playground/compiler/lower/clocks.test.ts new file mode 100644 index 00000000..4dfaf5ae --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/clocks.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOptions } from "../options"; +import { birthDeathIr, birthDeathOptions, birthDeathPython } from "../testing/birth-death.fixtures"; +import { pythonOf } from "../testing/compile-net"; +import { forkClockedIr, forkClockedOptions } from "../testing/fork.fixtures"; +import { and, assign, isZero, nonZero, ref } from "../graph/spn-graph"; +import { lowerPetriNetIr } from "./lower"; + +import type { SpnGraph } from "../graph/spn-graph"; +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** The SPN graph of an IR the clocks strategy accepts. */ +function lowerClocks(ir: PetriNetIr, options: CompilerOptions = { rates: "clock" }) { + const outcome = lowerPetriNetIr(ir, resolveOptions(options)); + if (!outcome.ok) { + throw new Error(outcome.errors.map((error) => error.code).join(", ")); + } + if (outcome.graph.language !== "spn") { + throw new Error("expected an SPN graph"); + } + return outcome.graph; +} + +describe("the clocks lowering", () => { + it("gives each transition a clock and an event, each place a count, and hides the clocks", () => { + // GIVEN the birth-death net under clock rates + // WHEN it is lowered + const graph = lowerClocks(birthDeathIr); + // THEN each transition drives a clock and an event, each place a count, + // and the clocks are hidden + expect( + graph.variables.map((variable) => [variable.name, variable.sort, variable.role]), + ).toEqual([ + ["t", "clock", "time"], + ["Population", "nat", "place"], + ["clk_Birth", "clock", "clock"], + ["clk_Death", "clock", "clock"], + ["ev_Birth", "event", "event"], + ["ev_Death", "event", "event"], + ]); + expect(graph.hidden).toEqual(["clk_Birth", "clk_Death"]); + expect( + graph.modules.map((module) => [ + module.className, + module.ctrl, + module.extl, + module.flow === undefined ? "no flow" : "flow", + ]), + ).toEqual([ + ["Transition_Birth", ["clk_Birth", "ev_Birth"], ["t"], "flow"], + ["Transition_Death", ["clk_Death", "ev_Death"], ["Population", "t"], "flow"], + ["Place_Population", ["Population"], ["ev_Birth", "ev_Death"], "no flow"], + ]); + }); + + it("ignores the shape, marking, control and dt options", () => { + // GIVEN the birth-death net with a controllable Death, with every step flag set and with none + const controllable: PetriNetIr = { + ...birthDeathIr, + transitions: { + ...birthDeathIr.transitions, + Death: { ...birthDeathIr.transitions.Death, controllable: true }, + }, + }; + // WHEN both are lowered under clocks + const busy = lowerClocks(controllable, { + rates: "clock", + shape: "monolithic", + marking: "int", + control: "open", + dt: 0.25, + }); + const plain: SpnGraph = lowerClocks(controllable); + // THEN the graphs agree + expect(busy).toEqual(plain); + }); + + it("gives each transition in a conflict a Bool pick it reads at the expiry, left in the interface", () => { + // GIVEN the clocked fork, with its conflict left open + // WHEN it is lowered, and lowered again with the conflict swept + const open = lowerClocks(forkClockedIr, forkClockedOptions); + const swept = lowerClocks(forkClockedIr); + // THEN each transition in the conflict reads its own pick, and only when its clock runs out + expect( + open.variables + .filter((variable) => variable.role === "pick") + .map((variable) => [variable.name, variable.sort, variable.comment]), + ).toEqual([ + ["pick_TakeLeft", "bool", "the environment lets TakeLeft fire when its clock expires"], + ["pick_TakeRight", "bool", "the environment lets TakeRight fire when its clock expires"], + ]); + expect(open.hidden).toEqual(["clk_TakeLeft", "clk_TakeRight", "clk_Return"]); + expect(open.modules.map((module) => [module.className, module.extl])).toEqual([ + ["Transition_TakeLeft", ["Pool", "pick_TakeLeft", "t"]], + ["Transition_TakeRight", ["Pool", "pick_TakeRight", "t"]], + ["Transition_Return", ["Left", "t"]], + ["Place_Pool", ["ev_Return", "ev_TakeLeft", "ev_TakeRight"]], + ["Place_Left", ["ev_TakeLeft", "ev_Return"]], + ["Place_Right", ["ev_TakeRight"]], + ]); + expect(open.modules[0]?.next).toEqual([ + assign( + "fires_TakeLeft", + and(and(isZero(ref("clk_TakeLeft")), nonZero(ref("Pool"))), ref("pick_TakeLeft")), + ), + ]); + // The pick is read at the expiry alone: the clock runs on the arcs. + expect(open.modules[0]?.flow).toEqual(swept.modules[0]?.flow); + }); +}); + +describe("the clocks lowering, as Python", () => { + it("compiles the birth-death net to the modules of Zeroth's birth_death.py", () => { + // GIVEN the birth-death net under clock rates + // WHEN it is compiled + const python = pythonOf(birthDeathIr, birthDeathOptions); + // THEN it matches Zeroth's own module + expect(python).toBe(birthDeathPython); + }); + + it("reads a Bool pick at the expiry of each transition in a conflict under conflicts nondet", () => { + // GIVEN the clocked fork with conflicts left open, swept by default, and swept by name + // WHEN each is compiled + const open = pythonOf(forkClockedIr, forkClockedOptions); + const sweep = pythonOf(forkClockedIr, { rates: "clock" }); + const named = pythonOf(forkClockedIr, { rates: "clock", conflicts: "sweep" }); + // THEN the open fork reads a pick in each transition of the conflict alone, and both swept forks have none + expect(open).toContain( + " fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft\n", + ); + expect(open).not.toContain("pick_Return"); + expect(sweep).not.toContain("pick_"); + expect(sweep).toBe(named); + }); + + it("tests read and inhibitor arcs, applies one exclusive case per mover, and leaves an untouched place alone", () => { + // GIVEN a clocked net with read and inhibitor arcs, a place moved three ways, + // a self-loop and a lone place + const clocked: PetriNetIr = { + name: "clocked", + kind: "stochastic", + places: { A: null, B: null, C: null, Lone: null }, + marking: { A: 3, B: 1 }, + transitions: { + In: { + inputs: { B: { kind: "read" }, C: { kind: "inhibitor" } }, + outputs: { A: null }, + rate: 0.5, + }, + Out: { inputs: { A: null }, rate: 1 }, + Move: { inputs: { A: null, B: null }, outputs: { C: null }, rate: 2 }, + Loop: { inputs: { B: null }, outputs: { B: null }, rate: 3 }, + }, + }; + // WHEN it is compiled + const python = pythonOf(clocked, { rates: "clock" }); + // THEN the arcs gate the firing and the flow, each place applies one mover at a time, + // and the lone place keeps its count + expect(python).toContain( + " fires_In = (clk_In == 0) & (B != 0) & (C == 0)\n return ite(fires_In, exp(0.5), clk_In), ite(fires_In, ~ev_In, None)\n\n def flow(self, clk_In, ev_In, B, C, t):\n return ite(clk_In >= 0, ite((B != 0) & (C == 0), -1 * d(t), 0 * d(t)), None), 0\n", + ); + expect(python).toContain(" fires_Move = (clk_Move == 0) & (A != 0) & (B != 0)\n"); + expect(python).toContain('"""Loop: B -> B, at rate 3"""'); + expect(python).toContain( + " def init(self, B, t):\n return exp(3.0), False\n\n def next(self, clk_Loop, ev_Loop, B, t):\n fires_Loop = (clk_Loop == 0) & (B != 0)\n", + ); + expect(python).toContain( + ' """A: added by In, taken by Out, Move"""\n\n def init(self, ev_In, ev_Out, ev_Move):\n return 3\n\n def next(self, A, ev_In, ev_Out, ev_Move):\n fired_In = fired(ev_In)\n fired_Out = fired(ev_Out)\n fired_Move = fired(ev_Move)\n return ite(fired_In & ~fired_Out & ~fired_Move, A + 1, ite(fired_Out & ~fired_In & ~fired_Move & (A != 0), A - 1, ite(fired_Move & ~fired_In & ~fired_Out & (A != 0), A - 1, A)))\n', + ); + expect(python).toContain( + ' """B: taken by Move"""\n\n def init(self, ev_Move):\n return 1\n\n def next(self, B, ev_Move):\n fired_Move = fired(ev_Move)\n return ite(fired_Move & (B != 0), B - 1, B)\n', + ); + expect(python).toContain(" return ite(fired_Move, C + 1, C)\n"); + expect(python).toContain( + ' """Lone: no transition moves its tokens"""\n\n def init(self):\n return 0\n\n def next(self, Lone):\n return Lone\n', + ); + expect(python).toContain("place_Lone = Place_Lone(theory=SPN, ctrl=(Lone,))\n"); + expect(python).toContain(" hide={clk_In, clk_Out, clk_Move, clk_Loop},\n"); + expect(python.match(/def flow\(/gu)).toHaveLength(4); + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/clocks.ts b/pocs/zeroth-playground/compiler/lower/clocks.ts new file mode 100644 index 00000000..792f4fc6 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/clocks.ts @@ -0,0 +1,204 @@ +import { + and, + assign, + bool, + conjunction, + dec, + exp, + fired, + ifThen, + inc, + isZero, + ite, + nat, + nonNegative, + nonZero, + not, + rate, + ref, + zeroFlow, + type SpnExpr, + type SpnModule, + type SpnGraph, + type SpnVariable, +} from "../graph/spn-graph"; +import { + clockName, + eventName, + firedName, + firesName, + pickName, + placeModuleNames, + timeReference, + transitionModuleNames, +} from "./names"; +import { clockRefusals } from "./clocks/refusals"; +import { pickApplies } from "./guard-inputs"; + +import type { Lowering } from "./lowering"; +import type { PlannedPlace, PlannedTransition, StepPlan } from "./step-plan"; + +/** + * The clocks strategy, after Zeroth's `birth_death.py` in the SPN theory: + * one module per transition owns a clock and an event, one module per + * place counts tokens as it awaits the events, and the clocks are hidden. + * + * A transition's clock is armed with an exponential delay at its rate, runs + * down against the time reference while its input arcs allow a firing, and + * is re-armed when it expires; the event toggles on the same step. A + * transition in a conflict the options leave open also needs the + * environment's pick, an external Bool nothing drives, at the expiry: with + * the pick false its clock stays at zero. A place applies one exclusive + * case per transition that moves its tokens: the theory moves one token at + * a time and time stops at the first expiry, so two events never toggle in + * one step. + */ + +function timeRate(factor: number): SpnExpr { + return rate(factor, timeReference); +} + +/** Each input arc as the test its place must pass: empty for an inhibitor arc, non-empty otherwise. */ +function arcTerms(transition: PlannedTransition): SpnExpr[] { + return transition.inputArcs.map((arc) => + arc.kind === "inhibitor" ? isZero(ref(arc.place)) : nonZero(ref(arc.place)), + ); +} + +function transitionModule(plan: StepPlan, transition: PlannedTransition): SpnModule { + if (transition.rate === null) { + throw new Error(`${transition.name} has no constant rate to arm a clock with`); + } + const clock = clockName(transition.name); + const event = eventName(transition.name); + const fires = firesName(transition.name); + const terms = arcTerms(transition); + const enabled = conjunction(terms); + const picks = pickApplies(plan, transition) ? [pickName(transition.name)] : []; + const armed = exp(transition.rate); + return { + ...transitionModuleNames(transition.name), + docstring: `${transition.description}, at rate ${transition.rate}`, + ctrl: [clock, event], + extl: [...transition.inputArcs.map((arc) => arc.place), ...picks, timeReference], + init: [armed, bool(false)], + next: [ + assign( + fires, + [...terms, ...picks.map(ref)].reduce((all, term) => and(all, term), isZero(ref(clock))), + ), + ], + returns: [ite(ref(fires), armed, ref(clock)), ifThen(ref(fires), not(ref(event)))], + flow: [ + ifThen( + nonNegative(clock), + enabled === null ? timeRate(-1) : ite(enabled, timeRate(-1), timeRate(0)), + ), + zeroFlow(), + ], + }; +} + +type Mover = { transition: string; adds: boolean }; + +/** The transitions that change a place's count: the producers first, then the consumers. */ +function moversOf(plan: StepPlan, place: string): Mover[] { + const deltas = plan.transitions.map((transition) => ({ + transition: transition.name, + delta: transition.deltas.get(place) ?? 0, + })); + return [ + ...deltas.filter(({ delta }) => delta > 0), + ...deltas.filter(({ delta }) => delta < 0), + ].map(({ transition, delta }) => ({ transition, adds: delta > 0 })); +} + +/** + * The count after the step: one case per mover, taken when its event alone + * toggled and, for a consumer, the place holds a token; the count otherwise. + */ +function nextCount(place: string, movers: Mover[]): SpnExpr { + return movers.reduceRight((rest, mover, index) => { + const others = movers.filter((_, otherIndex) => otherIndex !== index); + const guard = [ + ...others.map((other) => not(ref(firedName(other.transition)))), + ...(mover.adds ? [] : [nonZero(ref(place))]), + ].reduce((all, term) => and(all, term), ref(firedName(mover.transition))); + return ite(guard, mover.adds ? inc(ref(place)) : dec(ref(place)), rest); + }, ref(place)); +} + +function placeModule(plan: StepPlan, place: PlannedPlace): SpnModule { + const movers = moversOf(plan, place.name); + const adders = movers.filter((mover) => mover.adds); + const takers = movers.filter((mover) => !mover.adds); + const flows = [ + ...(adders.length === 0 + ? [] + : [`added by ${adders.map((mover) => mover.transition).join(", ")}`]), + ...(takers.length === 0 + ? [] + : [`taken by ${takers.map((mover) => mover.transition).join(", ")}`]), + ]; + return { + ...placeModuleNames(place.name), + docstring: `${place.name}: ${flows.length === 0 ? "no transition moves its tokens" : flows.join(", ")}`, + ctrl: [place.name], + extl: movers.map((mover) => eventName(mover.transition)), + init: [nat(place.initial)], + next: movers.map((mover) => + assign(firedName(mover.transition), fired(eventName(mover.transition))), + ), + returns: [nextCount(place.name, movers)], + }; +} + +function lowerClocks(plan: StepPlan): SpnGraph { + const variables: SpnVariable[] = [ + { + name: timeReference, + sort: "clock", + role: "time", + comment: "the time reference", + }, + ...plan.places.map((place): SpnVariable => ({ + name: place.name, + sort: "nat", + role: "place", + })), + ...plan.transitions.map((transition): SpnVariable => ({ + name: clockName(transition.name), + sort: "clock", + role: "clock", + comment: `time left until ${transition.name} fires`, + })), + ...plan.transitions.map((transition): SpnVariable => ({ + name: eventName(transition.name), + sort: "event", + role: "event", + comment: `toggles when ${transition.name} fires`, + })), + ...plan.transitions + .filter((transition) => pickApplies(plan, transition)) + .map((transition): SpnVariable => ({ + name: pickName(transition.name), + sort: "bool", + role: "pick", + comment: `the environment lets ${transition.name} fire when its clock expires`, + })), + ]; + return { + language: "spn", + variables, + modules: [ + ...plan.transitions.map((transition) => transitionModule(plan, transition)), + ...plan.places.map((place) => placeModule(plan, place)), + ], + hidden: plan.transitions.map((transition) => clockName(transition.name)), + }; +} + +export const clocks: Lowering = { + refusals: clockRefusals, + lower: lowerClocks, +}; diff --git a/pocs/zeroth-playground/compiler/lower/clocks/refusals.test.ts b/pocs/zeroth-playground/compiler/lower/clocks/refusals.test.ts new file mode 100644 index 00000000..e03d1fd1 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/clocks/refusals.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOptions } from "../../options"; +import { birthDeathIr } from "../../testing/birth-death.fixtures"; +import { parseCode } from "../../testing/fixture-parser"; +import { lowerPetriNetIr } from "../lower"; +import { clockRefusals } from "./refusals"; + +import type { PetriNetIr } from "../../ir/schema"; + +const clocks = resolveOptions({ rates: "clock" }); + +/** The refusals under clock rates, each as its code and item. */ +function refusals(ir: PetriNetIr) { + return clockRefusals(ir).map(({ code, item }) => ({ + code, + item: { kind: item.kind, name: item.name }, + })); +} + +describe("clockRefusals", () => { + it("accepts the birth-death net", () => { + // GIVEN the birth-death net, every transition at a constant rate + // WHEN it is checked + const found = refusals(birthDeathIr); + // THEN nothing is refused + expect(found).toEqual([]); + }); + + it("refuses a transition without a rate, a rate that reads its tokens and one that is not positive", () => { + // GIVEN a transition with no rate, one whose rate is code and one at rate 0 + const ir: PetriNetIr = { + ...birthDeathIr, + kind: "mixed", + transitions: { + Birth: { outputs: { Population: null } }, + Death: { + inputs: { Population: null }, + rate: "return input.Population.length;", + }, + Plague: { inputs: { Population: null }, rate: 0 }, + }, + }; + // WHEN the net is checked + const found = refusals(ir); + // THEN each transition is refused for its own reason + expect(found).toEqual([ + { + code: "clocks-plain-transition", + item: { kind: "transition", name: "Birth" }, + }, + { code: "clocks-rate-code", item: { kind: "transition", name: "Death" } }, + { + code: "clocks-rate-not-positive", + item: { kind: "transition", name: "Plague" }, + }, + ]); + }); + + it("refuses a guard and a kernel beside a rate, whether or not the code is parsed", () => { + // GIVEN a rated transition that also has a guard and a kernel + const guarded: PetriNetIr = { + ...birthDeathIr, + transitions: { + ...birthDeathIr.transitions, + Death: { + ...birthDeathIr.transitions.Death, + guard: "return input.Population.length > 2;", + kernel: "return {};", + }, + }, + }; + // WHEN it is checked, then lowered without and with a parser + const found = refusals(guarded); + const lowered = [lowerPetriNetIr(guarded, clocks), lowerPetriNetIr(guarded, clocks, parseCode)]; + // THEN the guard and the kernel are refused before the code is read + expect(found).toEqual([ + { code: "clocks-guard", item: { kind: "transition", name: "Death" } }, + { code: "clocks-kernel", item: { kind: "transition", name: "Death" } }, + ]); + for (const outcome of lowered) { + expect(outcome).toMatchObject({ + ok: false, + errors: [{ code: "clocks-guard" }, { code: "clocks-kernel" }], + }); + } + }); + + it("refuses a rate that reads its tokens before the code is read, parser or not", () => { + // GIVEN the birth-death net whose Death rate reads its tokens + const coded: PetriNetIr = { + ...birthDeathIr, + transitions: { + ...birthDeathIr.transitions, + Death: { inputs: { Population: null }, rate: "return input.Population.length;" }, + }, + }; + // WHEN it is lowered without and with a parser + const lowered = [lowerPetriNetIr(coded, clocks), lowerPetriNetIr(coded, clocks, parseCode)]; + // THEN both refuse the rate as code + for (const outcome of lowered) { + expect(outcome).toMatchObject({ ok: false, errors: [{ code: "clocks-rate-code" }] }); + } + }); + + it("refuses arcs that carry more than one token, naming each one", () => { + // GIVEN a transition with an input arc of weight 2 and an output arc of weight 3 + const ir: PetriNetIr = { + ...birthDeathIr, + places: { Population: null, Pairs: null }, + transitions: { + Pair: { + inputs: { Population: { weight: 2 } }, + outputs: { Pairs: null, Population: { weight: 3 } }, + rate: 1, + }, + }, + }; + // WHEN it is checked + const errors = clockRefusals(ir); + // THEN one refusal names both arcs + expect(errors).toEqual([ + { + code: "clocks-arc-weight", + message: + "the clocks strategy moves one token per arc; the arc from Population carries 2, the arc into Population carries 3", + item: { kind: "transition", name: "Pair" }, + }, + ]); + }); + + it("refuses a capacity", () => { + // GIVEN the birth-death net with a capped place + const ir: PetriNetIr = { + ...birthDeathIr, + places: { Population: { capacity: 10 } }, + }; + // WHEN the net is checked + const found = refusals(ir); + // THEN the place is refused: the counters test against zero only + expect(found).toEqual([ + { code: "clocks-capacity", item: { kind: "place", name: "Population" } }, + ]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/clocks/refusals.ts b/pocs/zeroth-playground/compiler/lower/clocks/refusals.ts new file mode 100644 index 00000000..61bd299a --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/clocks/refusals.ts @@ -0,0 +1,83 @@ +import { arcWeight } from "../../ir/accessors"; + +import type { Diagnostic } from "../../diagnostics"; +import type { PetriNetIr, PetriNetIrArcs } from "../../ir/schema"; + +/** The arcs of one side that carry more than one token, each as a clause. */ +function heavyArcs(arcs: PetriNetIrArcs | undefined, side: "from" | "into"): string[] { + return Object.entries(arcs ?? {}) + .filter(([, arc]) => arcWeight(arc) > 1) + .map(([place, arc]) => `the arc ${side} ${place} carries ${arcWeight(arc)}`); +} + +/** + * What the clocks strategy cannot express: it arms a clock with a constant + * positive rate, fires on the clock and the input arcs alone, counts plain + * tokens in Nat, tests a count against zero only and moves one token per + * arc. + */ +export function clockRefusals(ir: PetriNetIr): Diagnostic[] { + const errors: Diagnostic[] = []; + for (const [name, place] of Object.entries(ir.places)) { + const item = { kind: "place" as const, name }; + if (place?.capacity !== undefined) { + errors.push({ + code: "clocks-capacity", + message: + "the clocks strategy cannot test a capacity: the SPN theory tests a count against zero only", + item, + }); + } + } + for (const [name, transition] of Object.entries(ir.transitions)) { + const item = { kind: "transition" as const, name }; + if (transition.rate === undefined) { + errors.push({ + code: "clocks-plain-transition", + message: + "the clocks strategy arms a clock at the transition's rate, and this transition has none", + item, + }); + } else if (typeof transition.rate === "string") { + errors.push({ + code: "clocks-rate-code", + message: + "the clocks strategy arms a clock with a constant rate; this rate reads its tokens", + item, + }); + } else if (!(Number.isFinite(transition.rate) && transition.rate > 0)) { + errors.push({ + code: "clocks-rate-not-positive", + message: `the clocks strategy arms a clock with a positive rate; the rate is ${transition.rate}`, + item, + }); + } + if (transition.guard !== undefined) { + errors.push({ + code: "clocks-guard", + message: + "the clocks strategy fires on its clock and its arcs alone; this transition also has a guard", + item, + }); + } + if (transition.kernel !== undefined) { + errors.push({ + code: "clocks-kernel", + message: "the clocks strategy moves plain tokens; this transition has a kernel", + item, + }); + } + const heavy = [ + ...heavyArcs(transition.inputs, "from"), + ...heavyArcs(transition.outputs, "into"), + ]; + if (heavy.length > 0) { + errors.push({ + code: "clocks-arc-weight", + message: `the clocks strategy moves one token per arc; ${heavy.join(", ")}`, + item, + }); + } + } + return errors; +} diff --git a/pocs/zeroth-playground/compiler/lower/colour-layout.ts b/pocs/zeroth-playground/compiler/lower/colour-layout.ts new file mode 100644 index 00000000..ef6e41f4 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/colour-layout.ts @@ -0,0 +1,202 @@ +import { + binary, + bool, + ite, + num, + type LinearExpr, + type LinearVariable, + ref, +} from "../graph/linear-graph"; +import { attributeName, presentName } from "./names"; + +import type { Diagnostic } from "../diagnostics"; +import type { PetriNetIr, PetriNetIrAttribute, PetriNetIrToken } from "../ir/schema"; +import type { ResolvedOptions } from "../options"; +import type { AttributeRefusal, AttributeValue, TokenBinding } from "../code/linear-code"; + +/** + * How a coloured place becomes variables: a bounded number of slots, each + * with a present flag and one variable per attribute the theories can hold. + * A real or integer attribute is a Real, a boolean a Bool, a closed string + * a Real holding the index of its value; an open string or a uuid has no + * variable, and a read of it is refused where it happens. + * + * Present slots always form a dense prefix at the start of a step, as a + * token list does, so `input.P[i]` is the i-th present slot. + */ + +export type SlotAttribute = { + name: string; + sort: "real" | "bool"; + /** What the code reads it as. */ + reads: "number" | "boolean" | "string"; + /** A closed string's values; the variable holds an index into them. */ + codes?: readonly string[]; + /** Dynamics integrate it: a real attribute in the model's terms. */ + integrates: boolean; +}; + +export type PlaceLayout = { + place: string; + colour: string; + slots: number; + /** The slots are the place's capacity, which the guards enforce, so no token can overflow. */ + capped: boolean; + attributes: SlotAttribute[]; + /** Attributes the theories cannot hold, with the reason. */ + refused: Map; +}; + +function slotAttribute(name: string, attribute: PetriNetIrAttribute): SlotAttribute | string { + if (attribute === "real" || attribute === "integer") { + return { + name, + sort: "real", + reads: "number", + integrates: attribute === "real", + }; + } + if (attribute === "boolean") { + return { name, sort: "bool", reads: "boolean", integrates: false }; + } + if (attribute === "uuid") { + return "a 128-bit id has no sort in the theories"; + } + if (attribute === "string") { + return "an open string attribute has no code table; only a closed set of values does"; + } + return { + name, + sort: "real", + reads: "string", + codes: attribute.enum, + integrates: false, + }; +} + +/** The layout of every coloured place, keyed by place. */ +export function layoutColouredPlaces( + ir: PetriNetIr, + options: ResolvedOptions, + errors: Diagnostic[], +): Map { + const layouts = new Map(); + for (const [name, place] of Object.entries(ir.places)) { + const colourName = place?.colour; + if (colourName === undefined) { + continue; + } + // The schema checked that the colour is declared. + const colour = ir.colours?.[colourName] ?? {}; + const attributes: SlotAttribute[] = []; + const refused = new Map(); + for (const [attribute, type] of Object.entries(colour)) { + const lowered = slotAttribute(attribute, type); + if (typeof lowered === "string") { + refused.set(attribute, lowered); + } else { + attributes.push(lowered); + } + } + const slots = place?.capacity ?? options.slots; + const initial = ir.marking?.[name]; + if (Array.isArray(initial) && initial.length > slots) { + errors.push({ + code: "marking-exceeds-slots", + message: `the place starts with ${initial.length} tokens and has ${slots} slots; set a capacity or the slots option`, + item: { kind: "place", name }, + }); + } + layouts.set(name, { + place: name, + colour: colourName, + slots, + capped: place?.capacity !== undefined, + attributes, + refused, + }); + } + return layouts; +} + +/** The variables of a layout, present flags first per slot, in slot order. */ +export function layoutVariables(layout: PlaceLayout): LinearVariable[] { + const variables: LinearVariable[] = []; + for (let slot = 0; slot < layout.slots; slot++) { + variables.push({ + name: presentName(layout.place, slot), + sort: "bool", + role: "place", + comment: `${layout.place} slot ${slot} holds a token`, + }); + for (const attribute of layout.attributes) { + variables.push({ + name: attributeName(layout.place, slot, attribute.name), + sort: attribute.sort, + role: "place", + ...(attribute.codes === undefined + ? {} + : { + comment: `code: ${attribute.codes.map((code, index) => `${index} ${code}`).join(", ")}`, + }), + }); + } + } + return variables; +} + +/** The initial value of every layout variable, from the marking's token rows. */ +export function layoutInitialValues(layout: PlaceLayout, rows: PetriNetIrToken[]): LinearExpr[] { + const values: LinearExpr[] = []; + for (let slot = 0; slot < layout.slots; slot++) { + const row = rows[slot]; + values.push(bool(row !== undefined)); + for (const attribute of layout.attributes) { + const cell = row?.[attribute.name]; + if (attribute.sort === "bool") { + values.push(bool(cell === true)); + } else if (attribute.codes !== undefined) { + const code = typeof cell === "string" ? attribute.codes.indexOf(cell) : -1; + values.push(num(Math.max(code, 0))); + } else { + values.push(num(typeof cell === "number" ? cell : 0)); + } + } + } + return values; +} + +/** The number of present slots, as a sum of indicators over the given present expressions. */ +export function countExpr(layout: PlaceLayout, present: (slot: number) => LinearExpr): LinearExpr { + let total: LinearExpr | null = null; + for (let slot = 0; slot < layout.slots; slot++) { + const indicator = ite(present(slot), num(1), num(0)); + total = total === null ? indicator : binary("+", total, indicator); + } + return total ?? num(0); +} + +/** The token in a slot, as the code reads it: each attribute by its variable. */ +export function slotToken( + layout: PlaceLayout, + slot: number, + attributeExpr: (attribute: SlotAttribute) => LinearExpr = (attribute) => + ref(attributeName(layout.place, slot, attribute.name)), +): TokenBinding { + return { + attribute: (name): AttributeValue | AttributeRefusal | undefined => { + const attribute = layout.attributes.find((candidate) => candidate.name === name); + if (attribute !== undefined) { + return { + expr: attributeExpr(attribute), + sort: attribute.reads, + ...(attribute.codes === undefined ? {} : { codes: attribute.codes }), + }; + } + const refusal = layout.refused.get(name); + return refusal === undefined + ? undefined + : { refused: "attribute-not-lowerable", message: `${name}: ${refusal}` }; + }, + }; +} diff --git a/pocs/zeroth-playground/compiler/lower/guard-inputs.ts b/pocs/zeroth-playground/compiler/lower/guard-inputs.ts new file mode 100644 index 00000000..1468db20 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/guard-inputs.ts @@ -0,0 +1,145 @@ +import { + binary, + bool, + next, + num, + type LinearExpr, + type LinearModule, + type Theory, + type LinearVariable, +} from "../graph/linear-graph"; +import { choiceName, drawModuleNames, drawName, hitName, pickName } from "./names"; + +import type { PlannedTransition, StepPlan } from "./step-plan"; + +/** + * The parts of a transition's guard that do not depend on the marking, and + * the variables and modules they need: the draw test of a stochastic + * transition, the choice of a controllable one and the pick of one in a + * conflict the options leave open. Both shapes read them. + */ + +/** A coloured net or one with dynamics holds Reals, so it is lowered in LRA. */ +function needsReals(plan: StepPlan): boolean { + return plan.facts.coloured || plan.facts.dynamic; +} + +/** Whether the draw tests live in modules of their own, with Int places. */ +function drawsAreModules(plan: StepPlan): boolean { + return plan.facts.stochastic && plan.options.marking === "int" && !needsReals(plan); +} + +/** The theory the places and the transitions are typed in. */ +export function markingTheory(plan: StepPlan): Theory { + return needsReals(plan) || (plan.facts.stochastic && plan.options.marking === "real") + ? "LRA" + : "LIA"; +} + +function choiceApplies(plan: StepPlan, transition: PlannedTransition): boolean { + return transition.controllable && plan.options.control === "open"; +} + +/** + * Whether the transition waits for the environment's pick: it shares an + * input place with another and the options leave the conflict open. The pick + * is an input nothing drives, so every resolution is a run of the module. + */ +export function pickApplies(plan: StepPlan, transition: PlannedTransition): boolean { + return transition.conflicting && plan.options.conflicts === "nondet"; +} + +/** One input a guard reads: the variable it declares, the guard's term, and the variable the term reads. */ +type GuardInput = { declares: LinearVariable; term: LinearExpr; reads: string }; + +/** A Bool input the guard awaits as it is. */ +function boolInput(name: string, comment: string): GuardInput { + return { + declares: { name, sort: "bool", role: "input", comment }, + term: next(name), + reads: name, + }; +} + +/** A transition's draw, choice and pick inputs, in that order. */ +function guardInputs(plan: StepPlan, transition: PlannedTransition): GuardInput[] { + const { name, threshold } = transition; + const draw: GuardInput[] = + threshold === null + ? [] + : [ + { + declares: { + name: drawName(name), + sort: "real", + role: "input", + comment: `uniform draw for ${name}, each step`, + }, + ...(drawsAreModules(plan) + ? { term: next(hitName(name)), reads: hitName(name) } + : { + term: binary(">=", next(drawName(name)), num(threshold)), + reads: drawName(name), + }), + }, + ]; + return [ + ...draw, + ...(choiceApplies(plan, transition) + ? [boolInput(choiceName(name), `choice for ${name}, each step: it fires only when chosen`)] + : []), + ...(pickApplies(plan, transition) + ? [boolInput(pickName(name), `the environment lets ${name} fire this step`)] + : []), + ]; +} + +/** The draw, choice and pick inputs, in transition order, each declared once. */ +export function inputVariables(plan: StepPlan): LinearVariable[] { + return plan.transitions.flatMap((transition) => + guardInputs(plan, transition).map((input) => input.declares), + ); +} + +/** + * The guard terms that do not read the marking, with the variables they + * read: the draw test (or the draw module's flag), the choice and the pick. + */ +export function inputTerms( + plan: StepPlan, + transition: PlannedTransition, +): { terms: LinearExpr[]; reads: string[] } { + const inputs = guardInputs(plan, transition); + return { terms: inputs.map((input) => input.term), reads: inputs.map((input) => input.reads) }; +} + +/** The transitions whose draw is tested in a module of its own, in sweep order. */ +function drawTransitions(plan: StepPlan): PlannedTransition[] { + return drawsAreModules(plan) + ? plan.transitions.filter((transition) => transition.threshold !== null) + : []; +} + +/** The Bool each draw module drives. */ +export function hitVariables(plan: StepPlan): LinearVariable[] { + return drawTransitions(plan).map((transition) => ({ + name: hitName(transition.name), + sort: "bool", + role: "flag", + comment: `${transition.name}'s draw passed its threshold`, + })); +} + +/** One LRA module per transition: its draw against its threshold, as a Bool. */ +export function drawModules(plan: StepPlan): LinearModule[] { + return drawTransitions(plan).map((transition) => ({ + ...drawModuleNames(transition.name), + docstring: `${transition.name} at rate ${transition.rate ?? 0} fires within a step of dt = ${plan.options.dt} when its draw is at least e^(-${transition.rate ?? 0} * ${plan.options.dt})`, + theory: "LRA", + ctrl: [hitName(transition.name)], + extl: [drawName(transition.name)], + init: [bool(false)], + next: [], + returns: [binary(">=", next(drawName(transition.name)), num(transition.threshold ?? 0))], + })); +} diff --git a/pocs/zeroth-playground/compiler/lower/lower.test.ts b/pocs/zeroth-playground/compiler/lower/lower.test.ts new file mode 100644 index 00000000..164e9f83 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/lower.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { conflictingTransitions, initialTokens } from "../ir/accessors"; +import { resolveOptions } from "../options"; +import { birthDeathIr, birthDeathOptions } from "../testing/birth-death.fixtures"; +import { inputsFor, randomNet } from "../testing/random-net"; +import { referenceStep } from "../testing/reference-step"; +import { + runLinearGraph, + moduleAwaits, + orderLinearModules, + type LinearValue, +} from "../testing/run-linear-graph"; +import { lowerPetriNetIr } from "./lower"; + +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +function placesOnly(values: Record, ir: PetriNetIr): Record { + return Object.fromEntries(Object.keys(ir.places).map((place) => [place, Number(values[place])])); +} + +/** The linear graph of an IR the lowering accepts. */ +function lowerGraph(ir: PetriNetIr, options: CompilerOptions = {}) { + const outcome = lowerPetriNetIr(ir, resolveOptions(options)); + if (!outcome.ok) { + throw new Error(outcome.errors.map((error) => error.code).join(", ")); + } + if (outcome.graph.language === "spn") { + throw new Error("expected a graph in a linear theory"); + } + return outcome.graph; +} + +/** The reference trajectory: the initial marking, then one reference step per round. */ +function referenceTrajectory( + ir: PetriNetIr, + options: CompilerOptions, + inputs: (step: number, name: string) => LinearValue, + steps: number, +): Record[] { + const target = resolveOptions(options); + const expected: Record[] = [ + Object.fromEntries(Object.keys(ir.places).map((place) => [place, initialTokens(ir, place)])), + ]; + for (let step = 1; step <= steps; step++) { + expected.push( + referenceStep(ir, target, expected[step - 1] ?? {}, (name) => inputs(step, name)), + ); + } + return expected; +} + +// Clock rates are not among them: the interpreter models discrete rounds, +// and an SPN graph runs its clocks in continuous time. +const TARGETS: CompilerOptions[] = [ + {}, + { shape: "modular" }, + { marking: "int" }, + { shape: "modular", marking: "int" }, + { control: "open" }, + { shape: "modular", control: "open", marking: "int", dt: 0.25 }, +]; + +const STEPS = 25; + +describe("lowerPetriNetIr", () => { + it("gives every shape the trajectory of the reference step, on random nets", () => { + for (let seed = 1; seed <= 150; seed++) { + for (const stochastic of [false, true]) { + for (const options of TARGETS) { + // GIVEN a random net under one option set, and one value per input and round + const ir = randomNet(seed, stochastic); + const inputs = inputsFor(seed); + // WHEN its graph runs for a number of rounds + const trace = runLinearGraph(lowerGraph(ir, options), { + steps: STEPS, + inputs, + }).map((values) => placesOnly(values, ir)); + // THEN the places follow the reference step round by round + expect(trace, `seed ${seed} ${ir.kind} ${JSON.stringify(options)}`).toEqual( + referenceTrajectory(ir, options, inputs, STEPS), + ); + } + } + } + }); + + it("lets a transition module read its places latched and await only earlier transitions", () => { + for (let seed = 1; seed <= 60; seed++) { + // GIVEN a random net, modular with an integer marking + const ir = randomNet(seed, seed % 2 === 0); + const transitions = Object.keys(ir.transitions); + // WHEN it is lowered + const graph = lowerGraph(ir, { shape: "modular", marking: "int" }); + // THEN a transition module awaits only the firings of earlier transitions and its inputs + for (const module of graph.modules) { + if (!module.className.startsWith("Transition_")) { + continue; + } + const own = transitions.indexOf(module.className.slice("Transition_".length)); + for (const awaited of moduleAwaits(module)) { + if (awaited.startsWith("fire_")) { + const other = transitions.indexOf(awaited.slice("fire_".length)); + expect(other, `${module.className} awaits ${awaited}`).toBeLessThan(own); + } else { + expect(awaited).toMatch(/^(hit|go|u)_/u); + } + } + } + // Kahn's sort accepts it, as composition would. + expect(orderLinearModules(graph)).toHaveLength(graph.modules.length); + } + }); + + it("gives a pick to the transitions in a conflict alone, and takes an undriven pick as the sweep", () => { + for (let seed = 1; seed <= 60; seed++) { + for (const shape of ["monolithic", "modular"] as const) { + // GIVEN a random net in one shape, with its conflicts swept and left open + const ir = randomNet(seed, seed % 2 === 0); + const inputs = inputsFor(seed); + const open = lowerGraph(ir, { shape, conflicts: "nondet" }); + // WHEN both run, the open one with every pick left undriven + const sweep = runLinearGraph(lowerGraph(ir, { shape }), { steps: STEPS, inputs }); + const trace = runLinearGraph(open, { + steps: STEPS, + inputs: (step, name) => (name.startsWith("pick_") ? undefined : inputs(step, name)), + }); + // THEN only the transitions in a conflict get a pick, and both runs agree + expect( + open.variables + .filter((variable) => variable.name.startsWith("pick_")) + .map((variable) => [variable.name, variable.sort, variable.role]), + `seed ${seed} ${shape}`, + ).toEqual( + [...conflictingTransitions(ir.transitions)].map((name) => [ + `pick_${name}`, + "bool", + "input", + ]), + ); + expect( + trace.map((values) => placesOnly(values, ir)), + `seed ${seed} ${shape}`, + ).toEqual(sweep.map((values) => placesOnly(values, ir))); + } + } + }); + + it("holds a transition whose pick is false and refuses another input left without a value", () => { + // GIVEN a plain fork with its conflict left open, and the same fork with rates + const fork: PetriNetIr = { + name: "fork", + kind: "plain", + places: { Pool: null, Left: null, Right: null }, + marking: { Pool: 3 }, + transitions: { + TakeLeft: { inputs: { Pool: null }, outputs: { Left: null } }, + TakeRight: { inputs: { Pool: null }, outputs: { Right: null } }, + }, + }; + const rated: PetriNetIr = { + ...fork, + kind: "stochastic", + transitions: { + TakeLeft: { ...fork.transitions.TakeLeft, rate: 1 }, + TakeRight: { ...fork.transitions.TakeRight, rate: 1 }, + }, + }; + // WHEN the plain fork runs with TakeLeft's pick false + const trace = runLinearGraph(lowerGraph(fork, { conflicts: "nondet" }), { + steps: 3, + inputs: (_step, name) => name !== "pick_TakeLeft", + }); + // THEN every token goes right, and the rated fork refuses to run without its draws + expect(trace.at(-1)).toEqual({ Pool: 0, Left: 0, Right: 3 }); + expect(() => + runLinearGraph(lowerGraph(rated, { conflicts: "nondet" }), { + steps: 1, + inputs: () => undefined, + }), + ).toThrow(/u_TakeLeft has no value for round 1/u); + }); + + it("lowers clock rates to an SPN graph, which the step interpreter refuses to order or run", () => { + // GIVEN the birth-death net under clock rates + // WHEN it is lowered + const outcome = lowerPetriNetIr(birthDeathIr, resolveOptions(birthDeathOptions)); + // THEN it is an SPN graph, which the interpreter does not order or run + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + expect(outcome.graph.language).toBe("spn"); + expect(() => orderLinearModules(outcome.graph)).toThrow(/continuous time/u); + expect(() => runLinearGraph(outcome.graph, { steps: 1, inputs: () => 0 })).toThrow( + /continuous time/u, + ); + }); + + it("drives every place and flag from exactly one module", () => { + // GIVEN a random stochastic net, modular with an integer marking and open control + const ir = randomNet(3, true); + // WHEN it is lowered + const graph = lowerGraph(ir, { shape: "modular", marking: "int", control: "open" }); + // THEN each variable but the inputs has exactly one driver + const drivers = new Map(); + for (const module of graph.modules) { + for (const name of module.ctrl) { + drivers.set(name, (drivers.get(name) ?? 0) + 1); + } + } + for (const variable of graph.variables) { + expect(drivers.get(variable.name) ?? 0).toBe(variable.role === "input" ? 0 : 1); + } + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/lower.ts b/pocs/zeroth-playground/compiler/lower/lower.ts new file mode 100644 index 00000000..5ba0788b --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/lower.ts @@ -0,0 +1,55 @@ +import { clocks } from "./clocks"; +import { modular } from "./modular"; +import { monolithic } from "./monolithic"; +import { planStep } from "./step-plan"; + +import type { CodeParser } from "../code/code-tree"; +import type { Diagnostic } from "../diagnostics"; +import type { ModuleGraph } from "../graph/module-graph"; +import type { PetriNetIr } from "../ir/schema"; +import type { ResolvedOptions } from "../options"; +import type { Lowering } from "./lowering"; + +/** + * Lowers a Petri net IR to a module graph in the shape the options ask + * for: a reactive module graph in a linear theory under coin rates, an + * SPN module graph under clock rates. The graph carries no target syntax; + * an emitter turns it into a file. A construct the lowering cannot express + * is refused per item, with the IR left intact for the reader. + */ + +export type LowerPetriNetIrOutcome = + | { ok: true; graph: ModuleGraph } + | { ok: false; errors: Diagnostic[] }; + +function loweringFor(options: ResolvedOptions): Lowering { + return options.rates === "clock" ? clocks : options.shape === "modular" ? modular : monolithic; +} + +/** + * `options` are the options in force, each one that does not apply to the + * net already at its default. `parseCode` reads the document's code strings + * back into code trees. Without one, a document that carries code is + * refused with `code-not-parsed`. + * + * The lowering refuses what it cannot express before the step is planned, + * so a net its `refusals` reject has no code read and no refusals of code. + */ +export function lowerPetriNetIr( + ir: PetriNetIr, + options: ResolvedOptions, + parseCode?: CodeParser, +): LowerPetriNetIrOutcome { + const lowering = loweringFor(options); + const refused = lowering.refusals(ir); + if (refused.length > 0) { + return { ok: false, errors: refused }; + } + const errors: Diagnostic[] = []; + const plan = planStep(ir, options, parseCode, errors); + if (errors.length > 0) { + return { ok: false, errors }; + } + const graph = lowering.lower(plan, errors); + return errors.length > 0 ? { ok: false, errors } : { ok: true, graph }; +} diff --git a/pocs/zeroth-playground/compiler/lower/lowering.ts b/pocs/zeroth-playground/compiler/lower/lowering.ts new file mode 100644 index 00000000..4ed17108 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/lowering.ts @@ -0,0 +1,12 @@ +import type { Diagnostic } from "../diagnostics"; +import type { ModuleGraph } from "../graph/module-graph"; +import type { PetriNetIr } from "../ir/schema"; +import type { StepPlan } from "./step-plan"; + +/** One way to turn a net into a module graph: the monolithic, modular or clocks lowering. */ +export type Lowering = { + /** What this lowering cannot express, refused before the step is planned. */ + refusals(ir: PetriNetIr): Diagnostic[]; + /** The graph of a planned step; a construct it cannot express goes to `errors`. */ + lower(plan: StepPlan, errors: Diagnostic[]): ModuleGraph; +}; diff --git a/pocs/zeroth-playground/compiler/lower/marking-terms.ts b/pocs/zeroth-playground/compiler/lower/marking-terms.ts new file mode 100644 index 00000000..1ab55cc9 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/marking-terms.ts @@ -0,0 +1,19 @@ +import { binary, num, type LinearExpr } from "../graph/linear-graph"; + +import type { PlannedArc } from "./step-plan"; + +/** + * The guard terms that read the marking, shared by both shapes. Each shape + * passes the token count it computes: the monolithic sweep reads the place + * as consumed so far, a modular transition rebuilds it from earlier firings. + */ + +/** An input arc's test: the place holds at least the weight, or fewer under an inhibitor. */ +export function arcTest(arc: PlannedArc, tokens: LinearExpr): LinearExpr { + return binary(arc.kind === "inhibitor" ? "<" : ">=", tokens, num(arc.weight)); +} + +/** A producer's room test: the capped place's fill plus the net change stays within the capacity. */ +export function roomTest(fill: LinearExpr, delta: number, capacity: number): LinearExpr { + return binary("<=", binary("+", fill, num(delta)), num(capacity)); +} diff --git a/pocs/zeroth-playground/compiler/lower/modular.test.ts b/pocs/zeroth-playground/compiler/lower/modular.test.ts new file mode 100644 index 00000000..ee965807 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/modular.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "vitest"; + +import { bucketIr } from "../testing/coloured-nets.fixtures"; +import { compileNet, pythonOf } from "../testing/compile-net"; +import { parseCode } from "../testing/fixture-parser"; +import { capacity, conflict, cycle, queue } from "../testing/small-nets.fixtures"; +import { modular } from "./modular"; + +import type { PetriNetIr } from "../ir/schema"; + +describe("the modular lowering", () => { + it("composes one module per transition and per place in the modular shape", () => { + // GIVEN the cycle in the modular shape + // WHEN it is compiled + const python = pythonOf(cycle, { shape: "modular" }); + // THEN each transition and each place has its class, and compose joins them + for (const className of ["Transition_Go", "Transition_Back", "Place_A", "Place_B"]) { + expect(python).toContain(`class ${className}(Module):`); + } + expect(python).toContain("net = compose(transition_Go, transition_Back, place_A, place_B)\n"); + }); + + it("has a later producer of a capped place await the earlier transitions that move its tokens", () => { + // GIVEN the capped buffer in the modular shape + // WHEN it is compiled + const python = pythonOf(capacity, { shape: "modular" }); + // THEN PutRight awaits PutLeft's firing to count the buffer's fill + expect(python).toContain( + ` fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) # PutLeft added 1 + return (RightSource >= 1) & (fill_Buffer + 1 <= 2) +`, + ); + expect(python).toContain( + "transition_PutRight = Transition_PutRight(theory=LIA, ctrl=(fire_PutRight,), extl=(RightSource, Buffer, fire_PutLeft))", + ); + }); + + it("keeps Int places under marking int by moving each draw test into an LRA module", () => { + // GIVEN the queue, modular with an integer marking + // WHEN it is compiled + const python = pythonOf(queue, { shape: "modular", marking: "int", dt: 0.5 }); + // THEN each draw is tested in its own LRA module and the rest stays in LIA + expect(python).toBe( + `from zrth import LIA, LRA, Bool, Int, Real, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Waiting = Var(INT) +Served = Var(INT) + +u_Arrive = Var(REAL) # uniform draw for Arrive, each step +u_Serve = Var(REAL) # uniform draw for Serve, each step + +hit_Arrive = Var(BOOL) # Arrive's draw passed its threshold +hit_Serve = Var(BOOL) # Serve's draw passed its threshold +fire_Arrive = Var(BOOL) # Arrive fires this step +fire_Serve = Var(BOOL) # Serve fires this step + + +class Draw_Arrive(Module): + """Arrive at rate 2.5 fires within a step of dt = 0.5 when its draw is at least e^(-2.5 * 0.5)""" + + def init(self, u_Arrive): + return False + + def next(self, hit_Arrive, u_Arrive): + return X(u_Arrive) >= 0.2865047968601901 + + +class Draw_Serve(Module): + """Serve at rate 3 fires within a step of dt = 0.5 when its draw is at least e^(-3 * 0.5)""" + + def init(self, u_Serve): + return False + + def next(self, hit_Serve, u_Serve): + return X(u_Serve) >= 0.22313016014842982 + + +class Transition_Arrive(Module): + """Arrive: nothing -> Waiting, at rate 2.5""" + + def init(self, Waiting, hit_Arrive): + return False + + def next(self, fire_Arrive, Waiting, hit_Arrive): + return (Waiting + 1 <= 4) & X(hit_Arrive) + + +class Transition_Serve(Module): + """Serve: Waiting -> Served, at rate 3""" + + def init(self, Waiting, hit_Serve): + return False + + def next(self, fire_Serve, Waiting, hit_Serve): + return (Waiting >= 1) & X(hit_Serve) + + +class Place_Waiting(Module): + """Waiting: taken by Serve, added by Arrive""" + + def init(self, fire_Arrive, fire_Serve): + return 0 + + def next(self, Waiting, fire_Arrive, fire_Serve): + Waiting = ite(X(fire_Arrive), Waiting + 1, Waiting) # Arrive adds 1 + Waiting = ite(X(fire_Serve), Waiting - 1, Waiting) # Serve takes 1 + return Waiting + + +class Place_Served(Module): + """Served: added by Serve""" + + def init(self, fire_Serve): + return 0 + + def next(self, Served, fire_Serve): + Served = ite(X(fire_Serve), Served + 1, Served) # Serve adds 1 + return Served + + +draw_Arrive = Draw_Arrive(theory=LRA, ctrl=(hit_Arrive,), extl=(u_Arrive,)) +draw_Serve = Draw_Serve(theory=LRA, ctrl=(hit_Serve,), extl=(u_Serve,)) +transition_Arrive = Transition_Arrive(theory=LIA, ctrl=(fire_Arrive,), extl=(Waiting, hit_Arrive)) +transition_Serve = Transition_Serve(theory=LIA, ctrl=(fire_Serve,), extl=(Waiting, hit_Serve)) +place_Waiting = Place_Waiting(theory=LIA, ctrl=(Waiting,), extl=(fire_Arrive, fire_Serve)) +place_Served = Place_Served(theory=LIA, ctrl=(Served,), extl=(fire_Serve,)) +net = compose( + draw_Arrive, + draw_Serve, + transition_Arrive, + transition_Serve, + place_Waiting, + place_Served, +) +`, + ); + }); + + it("has a later taker from a shared place await the earlier one in the modular shape", () => { + // GIVEN the conflict in the modular shape + // WHEN it is compiled + const python = pythonOf(conflict, { shape: "modular" }); + // THEN TakeRight counts Pool after TakeLeft's take + expect(python).toContain( + ` def next(self, fire_TakeRight, Pool, fire_TakeLeft): + avail_Pool = Pool + avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 2, avail_Pool) # TakeLeft took 2 + return avail_Pool >= 1 +`, + ); + }); + + it("tests read and inhibitor arcs on the count the earlier takers left", () => { + // GIVEN a taker from A, then a transition that reads A and is inhibited by B + const guarded: PetriNetIr = { + name: "guarded", + kind: "plain", + places: { A: null, B: null, C: null }, + marking: { A: 1 }, + transitions: { + Take: { inputs: { A: null }, outputs: { C: null } }, + Check: { inputs: { A: { kind: "read" }, B: { kind: "inhibitor" } }, outputs: { C: null } }, + }, + }; + // WHEN it is compiled in the modular shape + const python = pythonOf(guarded, { shape: "modular" }); + // THEN Check reads both places and tests A after Take's take + expect(python).toContain( + ` avail_A = ite(X(fire_Take), avail_A - 1, avail_A) # Take took 1 + return (avail_A >= 1) & (B < 1) +`, + ); + expect(python).toContain( + "transition_Check = Transition_Check(theory=LIA, ctrl=(fire_Check,), extl=(A, fire_Take, B))", + ); + }); + + it("refuses a coloured net and a net with dynamics", () => { + // GIVEN the cycle with a coloured place, and the cycle with a place that has dynamics + const coloured: PetriNetIr = { + ...cycle, + colours: { Drop: { size: "real" } }, + places: { A: { colour: "Drop" }, B: null }, + }; + const dynamic: PetriNetIr = { + ...cycle, + colours: { Drop: { size: "real" } }, + dynamics: { Evaporate: { colour: "Drop", code: "return tokens;" } }, + places: { A: { dynamics: "Evaporate" }, B: null }, + }; + // WHEN the modular lowering checks each net, and the plain cycle + const found = [coloured, dynamic, cycle].map((ir) => + modular.refusals(ir).map((error) => error.code), + ); + // THEN both are refused on the net, and the plain cycle is not + expect(found).toEqual([["modular-coloured-not-lowered"], ["modular-coloured-not-lowered"], []]); + }); + + it("refuses a coloured net with code by name alone, parser or not", () => { + // GIVEN the bucket, whose transitions carry code, in the modular shape + // WHEN it is compiled without and with a parser + const outcomes = [ + compileNet(bucketIr, { shape: "modular" }), + compileNet(bucketIr, { shape: "modular" }, parseCode), + ]; + // THEN the shape is refused before any code is read + for (const { graph, errors } of outcomes) { + expect(graph).toBeNull(); + expect(errors.map((error) => error.code)).toEqual(["modular-coloured-not-lowered"]); + } + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/modular.ts b/pocs/zeroth-playground/compiler/lower/modular.ts new file mode 100644 index 00000000..f3b26b02 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/modular.ts @@ -0,0 +1,238 @@ +import { + assign, + bool, + changedBy, + changedWhen, + conjunction, + next, + num, + type LinearExpr, + type LinearModule, + type LinearGraph, + type LinearStatement, + type LinearVariable, + ref, +} from "../graph/linear-graph"; +import { netItem } from "../ir/net-item"; +import { netFacts } from "../options"; +import { + availableName, + fillName, + fireName, + placeModuleNames, + transitionModuleNames, +} from "./names"; +import { + drawModules, + hitVariables, + inputTerms, + inputVariables, + markingTheory, +} from "./guard-inputs"; +import { arcTest, roomTest } from "./marking-terms"; + +import type { Diagnostic } from "../diagnostics"; +import type { PetriNetIr } from "../ir/schema"; +import type { Lowering } from "./lowering"; +import type { PlannedPlace, PlannedTransition, StepPlan } from "./step-plan"; + +/** + * The modular shape, after Zeroth's own Petri net examples: one module per + * transition drives a Bool flag that says it fires this round, one module + * per place awaits the flags of its transitions and applies their tokens, + * and the modules are composed into the system. + * + * A transition module reads its places latched, so it rebuilds the view a + * sweep gives it by awaiting the flags of the earlier transitions that take + * from the same place, or move tokens in a capped place it fills: the + * firings are the ones one step of the net decides, and each place's next + * value is the step's result. The awaits run from a transition to earlier + * ones and from a place to its transitions, so they form a DAG. + */ + +/** Adds `name` once, keeping first-seen order. */ +function read(reads: string[], name: string): void { + if (!reads.includes(name)) { + reads.push(name); + } +} + +type Guard = { + statements: LinearStatement[]; + terms: LinearExpr[]; + reads: string[]; +}; + +/** + * The tokens an input place holds once the earlier transitions taking from + * it have consumed theirs: the place, less each earlier firing's weight. + */ +function availableTokens(place: string, earlier: PlannedTransition[], guard: Guard): LinearExpr { + const takers = earlier.filter((transition) => + transition.consumes.some(([taken]) => taken === place), + ); + read(guard.reads, place); + if (takers.length === 0) { + return ref(place); + } + const available = availableName(place); + guard.statements.push(assign(available, ref(place))); + for (const taker of takers) { + const weight = taker.consumes.find(([taken]) => taken === place)?.[1] ?? 0; + read(guard.reads, fireName(taker.name)); + guard.statements.push( + assign( + available, + changedWhen(next(fireName(taker.name)), ref(available), "-", weight), + `${taker.name} took ${weight}`, + ), + ); + } + return ref(available); +} + +/** + * The tokens a capped place would hold if the step ended after the earlier + * transitions: the place, plus each earlier firing's net change to it. + */ +function fillTokens(place: string, earlier: PlannedTransition[], guard: Guard): LinearExpr { + const movers = earlier.filter((transition) => (transition.deltas.get(place) ?? 0) !== 0); + read(guard.reads, place); + if (movers.length === 0) { + return ref(place); + } + const fill = fillName(place); + guard.statements.push(assign(fill, ref(place))); + for (const mover of movers) { + const delta = mover.deltas.get(place) ?? 0; + read(guard.reads, fireName(mover.name)); + guard.statements.push( + assign( + fill, + changedBy(next(fireName(mover.name)), ref(fill), delta), + `${mover.name} ${delta > 0 ? "added" : "took"} ${Math.abs(delta)}`, + ), + ); + } + return ref(fill); +} + +function transitionModule( + plan: StepPlan, + transition: PlannedTransition, + index: number, +): LinearModule { + const earlier = plan.transitions.slice(0, index); + const guard: Guard = { statements: [], terms: [], reads: [] }; + for (const arc of transition.inputArcs) { + guard.terms.push(arcTest(arc, availableTokens(arc.place, earlier, guard))); + } + for (const [place, capacity] of plan.capped) { + const delta = transition.deltas.get(place) ?? 0; + if (delta > 0) { + guard.terms.push(roomTest(fillTokens(place, earlier, guard), delta, capacity)); + } + } + const inputs = inputTerms(plan, transition); + guard.terms.push(...inputs.terms); + for (const name of inputs.reads) { + read(guard.reads, name); + } + const fires = conjunction(guard.terms); + const rate = transition.rate === null ? "" : `, at rate ${transition.rate}`; + return { + ...transitionModuleNames(transition.name), + docstring: `${transition.description}${rate}${fires === null ? ", always enabled" : ""}`, + theory: markingTheory(plan), + ctrl: [fireName(transition.name)], + extl: guard.reads, + init: [bool(false)], + next: guard.statements, + returns: [fires ?? bool(true)], + }; +} + +function placeModule(plan: StepPlan, place: PlannedPlace): LinearModule { + const { name } = place; + const movers = plan.transitions.flatMap((transition) => { + const delta = transition.deltas.get(name); + return delta === undefined ? [] : [{ transition: transition.name, delta }]; + }); + const statements: LinearStatement[] = movers.map(({ transition, delta }) => + assign( + name, + changedBy(next(fireName(transition)), ref(name), delta), + `${transition} ${delta > 0 ? "adds" : "takes"} ${Math.abs(delta)}`, + ), + ); + const takers = movers.filter(({ delta }) => delta < 0).map(({ transition }) => transition); + const adders = movers.filter(({ delta }) => delta > 0).map(({ transition }) => transition); + const flows = [ + ...(takers.length === 0 ? [] : [`taken by ${takers.join(", ")}`]), + ...(adders.length === 0 ? [] : [`added by ${adders.join(", ")}`]), + ]; + return { + ...placeModuleNames(name), + docstring: `${name}: ${flows.length === 0 ? "no transition moves its tokens" : flows.join(", ")}`, + theory: markingTheory(plan), + ctrl: [name], + extl: movers.map(({ transition }) => fireName(transition)), + init: [num(place.initial)], + next: statements, + returns: [ref(name)], + }; +} + +function lowerModular(plan: StepPlan): LinearGraph { + const theory = markingTheory(plan); + const placeVariables: LinearVariable[] = plan.places.map((place) => ({ + name: place.name, + sort: theory === "LRA" ? "real" : "int", + role: "place", + })); + const fireVariables: LinearVariable[] = plan.transitions.map((transition) => ({ + name: fireName(transition.name), + sort: "bool", + role: "flag", + comment: `${transition.name} fires this step`, + })); + const modules: LinearModule[] = [ + ...drawModules(plan), + ...plan.transitions.map((transition, index) => transitionModule(plan, transition, index)), + ...plan.places.map((place) => placeModule(plan, place)), + ]; + return { + language: "linear", + variables: [ + ...placeVariables, + ...inputVariables(plan), + ...hitVariables(plan), + ...fireVariables, + ], + modules, + root: { + kind: "compose", + modules: modules.map((module) => module.instance), + }, + }; +} + +/** The modular shape is not lowered for coloured places or dynamics yet. */ +function modularRefusals(ir: PetriNetIr): Diagnostic[] { + const { coloured, dynamic } = netFacts(ir); + return coloured || dynamic + ? [ + { + code: "modular-coloured-not-lowered", + message: + "the modular shape is not lowered for a net with coloured places or dynamics yet; use the monolithic shape", + item: netItem(ir.name), + }, + ] + : []; +} + +export const modular: Lowering = { + refusals: modularRefusals, + lower: lowerModular, +}; diff --git a/pocs/zeroth-playground/compiler/lower/monolithic.test.ts b/pocs/zeroth-playground/compiler/lower/monolithic.test.ts new file mode 100644 index 00000000..e214c0ce --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from "vitest"; + +import { pythonOf } from "../testing/compile-net"; +import { forkIr } from "../testing/fork.fixtures"; +import { capacity, conflict, cycle, queue } from "../testing/small-nets.fixtures"; + +import type { PetriNetIr } from "../ir/schema"; + +describe("the monolithic lowering", () => { + it("lowers a plain net to an LIA module with one step of the net per next", () => { + // GIVEN the plain cycle + // WHEN it is compiled + const python = pythonOf(cycle); + // THEN one LIA module sweeps the transitions and lands the produced tokens at the end + expect(python).toBe( + `from zrth import LIA, Int, Var +from zrth.sugar import Module, ite + +INT = Int([1, 1]) + +A = Var(INT) +B = Var(INT) + + +class Cycle(Module): + """Plain Petri net with 2 places and 2 transitions. One next is one step of the net.""" + + def init(self): + return 1, 0 + + def next(self, A, B): + # sweep in order; a firing consumes its input tokens at once + fire_Go = A >= 1 # Go: A -> B + A = ite(fire_Go, A - 1, A) + fire_Back = B >= 1 # Back: B -> A + B = ite(fire_Back, B - 1, B) + # end of step: produced tokens land + A = ite(fire_Back, A + 1, A) + B = ite(fire_Go, B + 1, B) + return A, B + + +net = Cycle(theory=LIA, ctrl=(A, B)) +`, + ); + }); + + it("tracks a capped place's pending fill so a later producer sees an earlier one", () => { + // GIVEN two producers into a buffer capped at 2 + // WHEN the net is compiled + const python = pythonOf(capacity); + // THEN the buffer's fill counts what earlier producers added this step + expect(python).toBe( + `from zrth import LIA, Int, Var +from zrth.sugar import Module, ite + +INT = Int([1, 1]) + +LeftSource = Var(INT) +RightSource = Var(INT) +Buffer = Var(INT) +Sink = Var(INT) + + +class Capacity(Module): + """Plain Petri net with 4 places and 3 transitions. One next is one step of the net.""" + + def init(self): + return 5, 5, 0, 0 + + def next(self, LeftSource, RightSource, Buffer, Sink): + # tokens a capped place would hold if the step ended now + fill_Buffer = Buffer + # sweep in order; a firing consumes its input tokens at once + fire_PutLeft = (LeftSource >= 1) & (fill_Buffer + 1 <= 2) # PutLeft: LeftSource -> Buffer + LeftSource = ite(fire_PutLeft, LeftSource - 1, LeftSource) + fill_Buffer = ite(fire_PutLeft, fill_Buffer + 1, fill_Buffer) + fire_PutRight = (RightSource >= 1) & (fill_Buffer + 1 <= 2) # PutRight: RightSource -> Buffer + RightSource = ite(fire_PutRight, RightSource - 1, RightSource) + fill_Buffer = ite(fire_PutRight, fill_Buffer + 1, fill_Buffer) + fire_Take = Buffer >= 1 # Take: Buffer -> Sink + Buffer = ite(fire_Take, Buffer - 1, Buffer) + fill_Buffer = ite(fire_Take, fill_Buffer - 1, fill_Buffer) + # end of step: produced tokens land + Sink = ite(fire_Take, Sink + 1, Sink) + return LeftSource, RightSource, fill_Buffer, Sink + + +net = Capacity(theory=LIA, ctrl=(LeftSource, RightSource, Buffer, Sink)) +`, + ); + }); + + it("tests a stochastic transition against an external draw over dt", () => { + // GIVEN the queue, with rates 2.5 and 3 over a step of 0.5 + const arrive = Math.exp(-2.5 * 0.5); + const serve = Math.exp(-3 * 0.5); + // WHEN it is compiled + const python = pythonOf(queue, { dt: 0.5 }); + // THEN each transition fires when its draw is at least e^(-rate * dt) + expect(python).toBe( + `from zrth import LRA, Real, Var +from zrth.sugar import Module, X, ite + +REAL = Real([1, 1]) + +Waiting = Var(REAL) +Served = Var(REAL) + +u_Arrive = Var(REAL) # uniform draw for Arrive, each step +u_Serve = Var(REAL) # uniform draw for Serve, each step + + +class Queue(Module): + """Stochastic Petri net with 2 places and 2 transitions, dt = 0.5. One next is one step of the net.""" + + def init(self, u_Arrive, u_Serve): + return 0.0, 0.0 + + def next(self, Waiting, Served, u_Arrive, u_Serve): + # tokens a capped place would hold if the step ended now + fill_Waiting = Waiting + # sweep in order; a firing consumes its input tokens at once + fire_Arrive = (fill_Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= ${arrive}) # Arrive: nothing -> Waiting + fill_Waiting = ite(fire_Arrive, fill_Waiting + 1.0, fill_Waiting) + fire_Serve = (Waiting >= 1.0) & (X(u_Serve) >= ${serve}) # Serve: Waiting -> Served + Waiting = ite(fire_Serve, Waiting - 1.0, Waiting) + fill_Waiting = ite(fire_Serve, fill_Waiting - 1.0, fill_Waiting) + # end of step: produced tokens land + Served = ite(fire_Serve, Served + 1.0, Served) + return fill_Waiting, Served + + +net = Queue(theory=LRA, ctrl=(Waiting, Served), extl=(u_Arrive, u_Serve)) +`, + ); + }); + + it("composes the monolithic step module with the draw modules under marking int", () => { + // GIVEN the queue, monolithic with an integer marking + // WHEN it is compiled + const python = pythonOf(queue, { marking: "int", dt: 0.5 }); + // THEN the one step module reads the draw modules' hits and compose joins them + expect(python).toContain("Waiting = Var(INT)\n"); + expect(python).toContain( + 'class Draw_Arrive(Module):\n """Arrive at rate 2.5 fires within a step of dt = 0.5 when its draw is at least e^(-2.5 * 0.5)"""', + ); + expect(python).toContain( + " fire_Arrive = (fill_Waiting + 1 <= 4) & X(hit_Arrive) # Arrive: nothing -> Waiting\n", + ); + expect(python).toContain( + "marking = Queue(theory=LIA, ctrl=(Waiting, Served), extl=(hit_Arrive, hit_Serve))\nnet = compose(draw_Arrive, draw_Serve, marking)\n", + ); + }); + + it("opens a controllable transition to an external choice under control open", () => { + // GIVEN the conflict with control open, monolithic and modular + // WHEN each is compiled + const python = pythonOf(conflict, { control: "open" }); + const modular = pythonOf(conflict, { shape: "modular", control: "open" }); + // THEN the controllable transition also waits for its choice + expect(python).toBe( + `from zrth import LIA, Bool, Int, Var +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +Pool = Var(INT) +Left = Var(INT) +Right = Var(INT) + +go_TakeLeft = Var(BOOL) # choice for TakeLeft, each step: it fires only when chosen + + +class Conflict(Module): + """Plain Petri net with 3 places and 2 transitions. One next is one step of the net.""" + + def init(self, go_TakeLeft): + return 3, 0, 0 + + def next(self, Pool, Left, Right, go_TakeLeft): + # sweep in order; a firing consumes its input tokens at once + fire_TakeLeft = (Pool >= 2) & X(go_TakeLeft) # TakeLeft: 2 Pool -> Left + Pool = ite(fire_TakeLeft, Pool - 2, Pool) + fire_TakeRight = Pool >= 1 # TakeRight: Pool -> Right + Pool = ite(fire_TakeRight, Pool - 1, Pool) + # end of step: produced tokens land + Left = ite(fire_TakeLeft, Left + 1, Left) + Right = ite(fire_TakeRight, Right + 1, Right) + return Pool, Left, Right + + +net = Conflict(theory=LIA, ctrl=(Pool, Left, Right), extl=(go_TakeLeft,)) +`, + ); + expect(modular).toContain( + " def next(self, fire_TakeLeft, Pool, go_TakeLeft):\n return (Pool >= 2) & X(go_TakeLeft)\n", + ); + }); + + it("fires a controllable transition whenever enabled under control closed", () => { + // GIVEN the conflict with control closed + // WHEN it is compiled + const python = pythonOf(conflict); + // THEN there is no choice, and the controllable transition fires when enabled + expect(python).not.toContain("go_TakeLeft"); + expect(python).toContain(" fire_TakeLeft = Pool >= 2 # TakeLeft: 2 Pool -> Left\n"); + }); + + it("has each transition in a conflict wait for an undriven pick under conflicts nondet", () => { + // GIVEN the conflict with conflicts left open, monolithic, then modular with control open too + // WHEN each is compiled + const python = pythonOf(conflict, { conflicts: "nondet" }); + const modular = pythonOf(conflict, { + shape: "modular", + control: "open", + conflicts: "nondet", + }); + // THEN each transition in the conflict also waits for its own pick + expect(python).toBe( + `from zrth import LIA, Bool, Int, Var +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +Pool = Var(INT) +Left = Var(INT) +Right = Var(INT) + +pick_TakeLeft = Var(BOOL) # the environment lets TakeLeft fire this step +pick_TakeRight = Var(BOOL) # the environment lets TakeRight fire this step + + +class Conflict(Module): + """Plain Petri net with 3 places and 2 transitions. One next is one step of the net.""" + + def init(self, pick_TakeLeft, pick_TakeRight): + return 3, 0, 0 + + def next(self, Pool, Left, Right, pick_TakeLeft, pick_TakeRight): + # sweep in order; a firing consumes its input tokens at once + fire_TakeLeft = (Pool >= 2) & X(pick_TakeLeft) # TakeLeft: 2 Pool -> Left + Pool = ite(fire_TakeLeft, Pool - 2, Pool) + fire_TakeRight = (Pool >= 1) & X(pick_TakeRight) # TakeRight: Pool -> Right + Pool = ite(fire_TakeRight, Pool - 1, Pool) + # end of step: produced tokens land + Left = ite(fire_TakeLeft, Left + 1, Left) + Right = ite(fire_TakeRight, Right + 1, Right) + return Pool, Left, Right + + +net = Conflict(theory=LIA, ctrl=(Pool, Left, Right), extl=(pick_TakeLeft, pick_TakeRight)) +`, + ); + expect(modular).toContain( + "go_TakeLeft = Var(BOOL) # choice for TakeLeft, each step: it fires only when chosen\npick_TakeLeft = Var(BOOL) # the environment lets TakeLeft fire this step\npick_TakeRight = Var(BOOL) # the environment lets TakeRight fire this step\n", + ); + expect(modular).toContain( + " def next(self, fire_TakeLeft, Pool, go_TakeLeft, pick_TakeLeft):\n return (Pool >= 2) & X(go_TakeLeft) & X(pick_TakeLeft)\n", + ); + expect(modular).toContain( + ` def next(self, fire_TakeRight, Pool, fire_TakeLeft, pick_TakeRight): + avail_Pool = Pool + avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 2, avail_Pool) # TakeLeft took 2 + return (avail_Pool >= 1) & X(pick_TakeRight) +`, + ); + expect(modular).toContain( + "transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft, pick_TakeRight))\n", + ); + }); + + it("gives a pick to the transitions in a conflict alone, and none under conflicts sweep", () => { + // GIVEN the fork with conflicts left open, and four nets with conflicts swept + const swept = [conflict, forkIr, cycle, capacity]; + // WHEN each is compiled + const python = pythonOf(forkIr, { conflicts: "nondet" }); + // THEN only the fork's two takers get a pick, and sweeping is the default + expect(python).toContain("pick_TakeLeft = Var(BOOL)"); + expect(python).toContain("pick_TakeRight = Var(BOOL)"); + expect(python).not.toContain("pick_Return"); + expect(python).toContain(" fire_Return = Left >= 1 # Return: Left -> Pool\n"); + for (const ir of swept) { + expect(pythonOf(ir, { conflicts: "sweep" })).toBe(pythonOf(ir)); + } + }); + + it("mixes predicate and stochastic transitions, drawing only for the rated ones", () => { + // GIVEN a mixed net with one rated and one plain transition + const mixed: PetriNetIr = { + name: "mixed", + kind: "mixed", + places: { A: null, B: null }, + marking: { A: 3 }, + transitions: { + Go: { inputs: { A: null }, outputs: { B: null }, rate: 2 }, + Back: { inputs: { B: null }, outputs: { A: null } }, + }, + }; + // WHEN it is compiled + const python = pythonOf(mixed); + // THEN only the rated transition draws + expect(python).toContain( + ' """Mixed Petri net with 2 places and 2 transitions, dt = 1.0. One next is one step of the net."""', + ); + expect(python).toContain("u_Go = Var(REAL) # uniform draw for Go, each step\n\n"); + expect(python).not.toContain("u_Back"); + expect(python).toContain( + ` fire_Go = (A >= 1.0) & (X(u_Go) >= ${Math.exp(-2)}) # Go: A -> B\n`, + ); + expect(python).toContain(" fire_Back = B >= 1.0 # Back: B -> A\n"); + expect(python).toContain("net = Mixed(theory=LRA, ctrl=(A, B), extl=(u_Go,))"); + }); + + it("writes a plain transition with no inputs as always enabled", () => { + // GIVEN a source transition with no inputs + const source: PetriNetIr = { + name: "source", + kind: "plain", + places: { Out: null }, + transitions: { Emit: { outputs: { Out: { weight: 2 } } } }, + }; + // WHEN it is compiled + const python = pythonOf(source); + // THEN it fires every step + expect(python).toContain(" # Emit: nothing -> 2 Out: always enabled\n"); + expect(python).toContain(" Out = Out + 2\n"); + expect(python).toContain("net = Source(theory=LIA, ctrl=(Out,))"); + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/monolithic.ts b/pocs/zeroth-playground/compiler/lower/monolithic.ts new file mode 100644 index 00000000..b3a3cb1d --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic.ts @@ -0,0 +1,415 @@ +import { + assign, + binary, + bool, + changedBy, + changedWhen, + comment, + next, + not, + num, + type LinearExpr, + type LinearModule, + type LinearGraph, + type LinearStatement, + type LinearVariable, + ref, + scale, +} from "../graph/linear-graph"; +import { Refusal } from "../diagnostics"; +import { lowerBindings } from "./monolithic/bindings"; +import { countExpr, layoutInitialValues, layoutVariables } from "./colour-layout"; +import { type Producer, lowerCompaction } from "./monolithic/compaction"; +import { lowerDynamics } from "./monolithic/dynamics"; +import { lowerKernel } from "./monolithic/kernels"; +import { + exponentialDrawName, + fillName, + fireName, + kernelDrawName, + netModuleNames, + overflowName, + presentName, +} from "./names"; +import { + drawModules, + hitVariables, + inputTerms, + inputVariables, + markingTheory, +} from "./guard-inputs"; +import { arcTest, roomTest } from "./marking-terms"; + +import type { Diagnostic } from "../diagnostics"; +import type { NetItem } from "../ir/net-item"; +import type { LinearCodeEnv } from "../code/linear-code"; +import type { Lowering } from "./lowering"; +import type { PlannedTransition, StepPlan } from "./step-plan"; + +/** + * The monolithic shape: one module drives every place, and its `next` is + * one step of the net. Dynamics take their Euler step first; transitions are + * swept in record order, a firing consumes its input tokens at once, + * produced tokens land at the end of the step, and a capped place tracks + * what it would hold if the step ended now so a later producer sees an + * earlier one's tokens. A coloured place is a bounded set of slots whose + * survivors close up before the produced tokens land. + * + * Under `marking: int` on a stochastic uncoloured net the draw tests move + * into one LRA module per transition, and the step module reads their Bool + * flags. + */ + +const SWEEP_COMMENT = "sweep in order; a firing consumes its input tokens at once"; +const LAND_COMMENT = "end of step: produced tokens land"; +const FILL_COMMENT = "tokens a capped place would hold if the step ended now"; +const DYNAMICS_COMMENT = "dynamics first: one Euler step on every present token"; + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +function floatText(value: number): string { + return Number.isInteger(value) ? `${value}.0` : `${value}`; +} + +type StepBody = { + body: LinearStatement[]; + returns: LinearExpr[]; + /** Draw inputs the kernels take, in allocation order. */ + drawVariables: LinearVariable[]; + /** Coloured places whose produced tokens can overflow their slots. */ + overflowing: string[]; +}; + +/** The present flag of a slot, as the sweep has left it so far. */ +function present(place: string, slot: number): LinearExpr { + return ref(presentName(place, slot)); +} + +/** Draws a kernel takes: a standard normal per Gaussian, a uniform per Uniform with constant bounds. */ +function createSampler( + transition: PlannedTransition, + drawVariables: LinearVariable[], +): LinearCodeEnv["sample"] { + let normals = 0; + let uniforms = 0; + function drawInput(kind: "z" | "v", index: number, draw: string): LinearExpr { + const name = kernelDrawName(transition.name, kind, index); + drawVariables.push({ + name, + sort: "real", + role: "input", + comment: `${draw} for ${transition.name}, each step`, + }); + return next(name); + } + return (kind, args) => { + const [first, second] = args; + if (kind === "gaussian" && first?.sort === "number" && second?.expr.kind === "num") { + return binary( + "+", + first.expr, + scale(second.expr.value, drawInput("z", normals++, "standard normal draw")), + ); + } + if (kind === "uniform" && first?.expr.kind === "num" && second?.expr.kind === "num") { + const low = first.expr.value; + return binary( + "+", + num(low), + scale(second.expr.value - low, drawInput("v", uniforms++, "uniform draw")), + ); + } + return undefined; + }; +} + +/** Lowers one item's part of the step; a refusal becomes a diagnostic on the item. */ +function attempt(item: NetItem, errors: Diagnostic[], compute: () => T): T | undefined { + try { + return compute(); + } catch (error) { + if (error instanceof Refusal) { + errors.push({ code: error.code, message: error.message, item }); + return undefined; + } + throw error; + } +} + +/** 1. Dynamics, before anything fires: one Euler step per present token. */ +function dynamicsStatements(plan: StepPlan, errors: Diagnostic[]): LinearStatement[] { + const dynamic = plan.places.flatMap(({ name, layout, dynamics }) => + layout === null || dynamics === null ? [] : [{ name, layout, dynamics }], + ); + return dynamic.length === 0 + ? [] + : [ + comment(DYNAMICS_COMMENT), + ...dynamic.flatMap( + ({ name, layout, dynamics }) => + attempt({ kind: "place", name }, errors, () => + lowerDynamics(layout, dynamics, plan.options.dt), + ) ?? [], + ), + ]; +} + +/** 2. The tokens each capped place holds before the sweep, which every mover updates. */ +function fillStatements(plan: StepPlan): LinearStatement[] { + return plan.capped.size === 0 + ? [] + : [ + comment(FILL_COMMENT), + ...[...plan.capped.keys()].map((place) => { + const layout = plan.layouts.get(place); + return assign( + fillName(place), + layout === undefined ? ref(place) : countExpr(layout, (slot) => present(place, slot)), + ); + }), + ]; +} + +function stepStatements(plan: StepPlan, errors: Diagnostic[]): StepBody { + const drawVariables: LinearVariable[] = []; + const body: LinearStatement[] = [ + ...dynamicsStatements(plan, errors), + ...fillStatements(plan), + comment(SWEEP_COMMENT), + ]; + + // 3. The sweep. + const produced = new Map( + plan.places.map((place) => [place.name, []]), + ); + const colouredProducers = new Map( + [...plan.layouts.keys()].map((place) => [place, []]), + ); + for (const transition of plan.transitions) { + const item = { kind: "transition" as const, name: transition.name }; + const shared: LinearExpr[] = []; + for (const arc of transition.inputArcs) { + const layout = plan.layouts.get(arc.place); + if (layout === undefined) { + shared.push(arcTest(arc, ref(arc.place))); + } else if (arc.kind === "inhibitor") { + shared.push( + arcTest( + arc, + countExpr(layout, (slot) => present(arc.place, slot)), + ), + ); + } + } + for (const [place, capacity] of plan.capped) { + const delta = transition.deltas.get(place) ?? 0; + if (delta > 0) { + shared.push(roomTest(ref(fillName(place)), delta, capacity)); + } + } + shared.push(...inputTerms(plan, transition).terms); + const sample = createSampler(transition, drawVariables); + + const bindings = attempt(item, errors, () => + lowerBindings({ + plan, + transition, + sharedTerms: shared, + present, + sample, + }), + ); + if (bindings === undefined) { + continue; + } + body.push(...bindings.statements); + + const fire = bindings.fire === null ? null : fireName(transition.name); + if (bindings.fire === null) { + body.push(comment(`${transition.description}: always enabled`)); + } else { + body.push(assign(fireName(transition.name), bindings.fire, transition.description)); + } + const fireRef = fire === null ? null : ref(fire); + + // Consumption: counts for plain places, present flags for coloured ones. + for (const [place, weight] of transition.consumes) { + if (!plan.layouts.has(place)) { + body.push(assign(place, changedWhen(fireRef, ref(place), "-", weight))); + } + } + for (const take of bindings.takes) { + const taken = take.take === null ? fireRef : ref(take.take); + const flag = presentName(take.place, take.slot); + body.push(assign(flag, taken === null ? ref(flag) : binary("&", ref(flag), not(taken)))); + } + for (const place of plan.capped.keys()) { + const delta = transition.deltas.get(place) ?? 0; + if (delta !== 0) { + const fill = fillName(place); + body.push(assign(fill, changedBy(fireRef, ref(fill), delta))); + } + } + + // Production: plain counts land at the end; coloured tokens are computed now, landed later. + const colouredOutputs = transition.produces.filter(([place]) => plan.layouts.has(place)); + let outs = new Map(); + if (colouredOutputs.length > 0) { + if (transition.kernel === null) { + errors.push({ + code: "kernel-missing", + message: "the transition produces coloured tokens without a kernel", + item, + }); + } else { + const kernelFn = transition.kernel; + const kernel = attempt(item, errors, () => + lowerKernel(plan, transition, kernelFn, bindings.boundToken, sample), + ); + if (kernel !== undefined) { + body.push(...kernel.statements); + outs = kernel.outs; + } + } + } + for (const [place, weight] of transition.produces) { + if (plan.layouts.has(place)) { + colouredProducers.get(place)?.push({ fire: fireRef, tokens: outs.get(place) ?? [] }); + } else { + produced.get(place)?.push({ fire, weight }); + } + } + } + + // 4. Landing. + body.push(comment(LAND_COMMENT)); + const returns: LinearExpr[] = []; + const overflowing: string[] = []; + for (const place of plan.places) { + if (place.layout !== null) { + const { statements, overflows } = lowerCompaction( + place.layout, + colouredProducers.get(place.name) ?? [], + ); + body.push(...statements); + if (overflows) { + overflowing.push(place.name); + } + for (const variable of layoutVariables(place.layout)) { + returns.push(ref(variable.name)); + } + continue; + } + if (plan.capped.has(place.name)) { + returns.push(ref(fillName(place.name))); + continue; + } + for (const { fire, weight } of produced.get(place.name) ?? []) { + body.push( + assign( + place.name, + changedWhen(fire === null ? null : ref(fire), ref(place.name), "+", weight), + ), + ); + } + returns.push(ref(place.name)); + } + for (const place of overflowing) { + returns.push(ref(overflowName(place))); + } + return { body, returns, drawVariables, overflowing }; +} + +/** `Plain Petri net with 2 places and 2 transitions. One next is one step of the net.` */ +function netDocstring(plan: StepPlan): string { + const coloured = plan.layouts.size; + const kind = plan.kind === "mixed" ? "Mixed" : plan.facts.stochastic ? "Stochastic" : "Plain"; + const net = `${kind} ${coloured > 0 ? "coloured " : ""}Petri net`; + const places = `${plural(plan.places.length, "place")}${coloured > 0 ? ` (${coloured} coloured)` : ""}`; + const transitions = plural(plan.transitions.length, "transition"); + const dt = + plan.facts.stochastic || plan.facts.dynamic ? `, dt = ${floatText(plan.options.dt)}` : ""; + return `${net} with ${places} and ${transitions}${dt}. One next is one step of the net.`; +} + +function lowerMonolithic(plan: StepPlan, errors: Diagnostic[]): LinearGraph { + const theory = markingTheory(plan); + const placeVariables: LinearVariable[] = plan.places.flatMap((place) => + place.layout === null + ? [ + { + name: place.name, + sort: theory === "LRA" ? "real" : "int", + role: "place", + } satisfies LinearVariable, + ] + : layoutVariables(place.layout), + ); + const draws = drawModules(plan); + const { body, returns, drawVariables, overflowing } = stepStatements(plan, errors); + // A rate written as code is tested against an exponential draw per step. + const exponentialInputs: LinearVariable[] = plan.transitions + .filter((transition) => transition.rateCode !== null) + .map((transition) => ({ + name: exponentialDrawName(transition.name), + sort: "real", + role: "input", + comment: `exponential draw for ${transition.name}, each step: -ln(u) / dt`, + })); + const extl = [ + ...plan.transitions.flatMap((transition) => inputTerms(plan, transition).reads), + ...exponentialInputs.map((variable) => variable.name), + ...drawVariables.map((variable) => variable.name), + ]; + const overflowVariables: LinearVariable[] = overflowing.map((place) => ({ + name: overflowName(place), + sort: "bool", + role: "flag", + comment: `a token produced into ${place} found no free slot, at some step`, + })); + const init: LinearExpr[] = plan.places.flatMap((place) => + place.layout === null ? [num(place.initial)] : layoutInitialValues(place.layout, place.rows), + ); + init.push(...overflowing.map(() => bool(false))); + const names = netModuleNames(plan.name); + const step: LinearModule = { + ...names, + docstring: netDocstring(plan), + theory, + ctrl: [ + ...placeVariables.map((variable) => variable.name), + ...overflowVariables.map((variable) => variable.name), + ], + extl, + init, + next: body, + returns, + }; + return { + language: "linear", + variables: [ + ...placeVariables, + ...inputVariables(plan), + ...exponentialInputs, + ...drawVariables, + ...hitVariables(plan), + ...overflowVariables, + ], + modules: [...draws, step], + root: + draws.length === 0 + ? { kind: "single", module: names.instance } + : { + kind: "compose", + modules: [...draws.map((draw) => draw.instance), names.instance], + }, + }; +} + +/** Refuses nothing up front: what it cannot express shows as it lowers each item. */ +export const monolithic: Lowering = { + refusals: () => [], + lower: lowerMonolithic, +}; diff --git a/pocs/zeroth-playground/compiler/lower/monolithic/bindings.ts b/pocs/zeroth-playground/compiler/lower/monolithic/bindings.ts new file mode 100644 index 00000000..416032af --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic/bindings.ts @@ -0,0 +1,268 @@ +import { + assign, + binary, + conjunction, + disjunction, + ite, + next, + not, + type LinearExpr, + type LinearStatement, + ref, +} from "../../graph/linear-graph"; +import { slotToken } from "../colour-layout"; +import { refuse, within } from "../../diagnostics"; +import { + type LinearCodeEnv, + type TokenBinding, + translateGuard, + translateRate, +} from "../../code/linear-code"; +import { + bindName, + enabledName, + exponentialDrawName, + presentName, + seenName, + selectName, + takeName, +} from "../names"; +import { inputTokenCount } from "../step-plan"; + +import type { CodeFunction } from "../../code/code-tree"; +import type { PlannedArc, PlannedTransition, StepPlan } from "../step-plan"; + +/** + * A transition's bindings: which tokens of its coloured input places it + * takes, in the order a step tries them. A step enumerates the + * combinations of token indices per arc, ascending and lexicographic, with + * the last arc advancing fastest, and fires the first one whose guard holds. + * Here every combination gets a Bool, the first passing one is selected with + * an exclusion chain, and a bound attribute the kernel reads is the `ite` + * chain over the selections. + * + * Because present slots form a dense prefix and consumption only clears + * flags in slot order, the k-th present slot is the token at index k. + */ + +export type Bindings = { + statements: LinearStatement[]; + /** The firing condition, or `null` when nothing can stop the firing. */ + fire: LinearExpr | null; + /** Slots the firing consumes, with the Bool that says so; `null` means the firing itself. */ + takes: { place: string; slot: number; take: string | null }[]; + /** `input.[index]` as the kernel sees it: the bound token's attributes. */ + boundToken: (place: string, index: number) => TokenBinding | undefined; +}; + +export type BindingsContext = { + plan: StepPlan; + transition: PlannedTransition; + /** Terms every combination shares: counts, capacities, the draw or choice. */ + sharedTerms: LinearExpr[]; + /** The present flag of a slot as the sweep has left it so far. */ + present: (place: string, slot: number) => LinearExpr; + sample: LinearCodeEnv["sample"]; +}; + +/** Ascending k-subsets of 0..n-1 in lexicographic order. */ +function combinations(count: number, size: number): number[][] { + const result: number[][] = []; + function build(start: number, chosen: number[]): void { + if (chosen.length === size) { + result.push([...chosen]); + return; + } + for (let slot = start; slot < count; slot++) { + chosen.push(slot); + build(slot + 1, chosen); + chosen.pop(); + } + } + build(0, []); + return result; +} + +/** The cartesian product in arc order, the last arc advancing fastest. */ +function product(perArc: number[][][]): number[][][] { + return perArc.reduce( + (acc, tuples) => acc.flatMap((prefix) => tuples.map((tuple) => [...prefix, tuple])), + [[]], + ); +} + +/** The arcs whose tokens carry attributes and are bound: coloured, not inhibiting. */ +function bindingArcs(plan: StepPlan, transition: PlannedTransition): PlannedArc[] { + return transition.inputArcs.filter( + (arc) => arc.kind !== "inhibitor" && plan.layouts.has(arc.place), + ); +} + +function envFor( + context: BindingsContext, + arcs: PlannedArc[], + combination: number[][], +): LinearCodeEnv { + return { + inputName: "input", + token: (place, index) => { + const position = arcs.findIndex((arc) => arc.place === place); + const layout = context.plan.layouts.get(place); + const slot = combination[position]?.[index]; + return position === -1 || layout === undefined || slot === undefined + ? undefined + : slotToken(layout, slot); + }, + tokenCount: inputTokenCount(context.transition), + sample: context.sample, + }; +} + +/** The guard or rate test of one combination, or `null` when the code reads nothing. */ +function lambdaTerm(context: BindingsContext, env: LinearCodeEnv): LinearExpr | null { + const { transition } = context; + function named(fn: CodeFunction): LinearCodeEnv { + return { + ...env, + inputName: fn.params[0]?.name ?? env.inputName, + }; + } + if (transition.guard !== null) { + const guard = transition.guard; + return within("guard", () => translateGuard(guard, named(guard))); + } + if (transition.rateCode !== null) { + const rate = transition.rateCode; + // exp(-rate * dt) <= u is rate >= -ln(u) / dt, the exponential draw. + return within("rate", () => + binary(">=", translateRate(rate, named(rate)), next(exponentialDrawName(transition.name))), + ); + } + return null; +} + +export function lowerBindings(context: BindingsContext): Bindings { + const { plan, transition } = context; + const arcs = bindingArcs(plan, transition); + const perArc = arcs.map((arc) => + combinations(plan.layouts.get(arc.place)?.slots ?? 0, arc.weight), + ); + const tuples = product(perArc); + if (tuples.length > 4096) { + refuse( + "binding-explosion", + `${tuples.length} token combinations to try; the module would be too large`, + ); + } + const statements: LinearStatement[] = []; + const shared = conjunction(context.sharedTerms); + + function presenceTerms(combination: number[][]): LinearExpr[] { + return arcs.flatMap((arc, position) => + (combination[position] ?? []).map((slot) => context.present(arc.place, slot)), + ); + } + + // One combination: the firing is the conjunction, with no selection chain. + if (tuples.length === 1) { + const [combination] = tuples; + const env = envFor(context, arcs, combination ?? []); + const lambda = lambdaTerm(context, env); + const fire = conjunction([ + ...(shared === null ? [] : [shared]), + ...presenceTerms(combination ?? []), + ...(lambda === null ? [] : [lambda]), + ]); + const takes = arcs.flatMap((arc, position) => + arc.kind === "standard" + ? (combination?.[position] ?? []).map((slot) => ({ + place: arc.place, + slot, + take: null, + })) + : [], + ); + return { + statements, + fire, + takes, + boundToken: (place, index) => envFor(context, arcs, combination ?? []).token(place, index), + }; + } + + // Several combinations: shared terms once, one Bool per combination, first match wins. + const enabled = enabledName(transition.name); + if (shared !== null) { + statements.push(assign(enabled, shared, "every combination needs this")); + } + const seen = seenName(transition.name); + tuples.forEach((combination, index) => { + const env = envFor(context, arcs, combination); + const lambda = lambdaTerm(context, env); + const terms = [ + ...(shared === null ? [] : [ref(enabled)]), + ...presenceTerms(combination), + ...(lambda === null ? [] : [lambda]), + ]; + const described = arcs + .map((arc, position) => `${arc.place}[${(combination[position] ?? []).join(", ")}]`) + .join(" "); + statements.push( + assign(bindName(transition.name, index), conjunction(terms) ?? ref(enabled), described), + ); + if (index === 0) { + statements.push(assign(selectName(transition.name, 0), ref(bindName(transition.name, 0)))); + statements.push(assign(seen, ref(bindName(transition.name, 0)))); + } else { + statements.push( + assign( + selectName(transition.name, index), + binary("&", ref(bindName(transition.name, index)), not(ref(seen))), + ), + ); + statements.push(assign(seen, binary("|", ref(seen), ref(bindName(transition.name, index))))); + } + }); + + const takes: Bindings["takes"] = []; + arcs.forEach((arc, position) => { + if (arc.kind !== "standard") { + return; + } + const slots = new Set(tuples.flatMap((combination) => combination[position] ?? [])); + for (const slot of [...slots].toSorted((left, right) => left - right)) { + const selections = tuples + .map((combination, index) => ({ combination, index })) + .filter(({ combination }) => (combination[position] ?? []).includes(slot)) + .map(({ index }) => ref(selectName(transition.name, index))); + const take = takeName(transition.name, arc.place, slot); + statements.push(assign(take, disjunction(selections) ?? ref(seen))); + takes.push({ place: arc.place, slot, take }); + } + }); + + function boundToken(place: string, index: number): TokenBinding | undefined { + const position = arcs.findIndex((arc) => arc.place === place); + const layout = plan.layouts.get(place); + if (position === -1 || layout === undefined) { + return undefined; + } + const slots = tuples.map((combination) => combination[position]?.[index]); + if (slots.some((slot) => slot === undefined)) { + return undefined; + } + return slotToken(layout, 0, (attribute) => { + // The chosen combination's slot, first match first; the last stands unguarded. + let chain: LinearExpr | null = null; + for (let choice = tuples.length - 1; choice >= 0; choice--) { + const slot = slots[choice] ?? 0; + const value = slotToken(layout, slot).attribute(attribute.name); + const expr = value !== undefined && !("refused" in value) ? value.expr : ref("_"); + chain = chain === null ? expr : ite(ref(selectName(transition.name, choice)), expr, chain); + } + return chain ?? ref(presentName(place, 0)); + }); + } + + return { statements, fire: ref(seen), takes, boundToken }; +} diff --git a/pocs/zeroth-playground/compiler/lower/monolithic/colour.test.ts b/pocs/zeroth-playground/compiler/lower/monolithic/colour.test.ts new file mode 100644 index 00000000..5e6ab76b --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic/colour.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +import { + boilerIr, + boilerOptions, + bucketIr, + bucketPython, + dronesIr, + dronesOptions, +} from "../../testing/coloured-nets.fixtures"; +import { + array, + dynamicsOf, + lambdaOf, + lit, + local, + op, + record, + text, + token, + tokenAttribute, +} from "../../testing/code-builders"; +import { compileNet, pythonOf } from "../../testing/compile-net"; +import { parseCode } from "../../testing/fixture-parser"; + +import type { CodeExpr, CodeFunction, CodeParser } from "../../code/code-tree"; +import type { DiagnosticCode } from "../../diagnostics"; +import type { PetriNetIr } from "../../ir/schema"; + +/** The code a hangar's Launch or its place carries, as trees. */ +type HangarCode = { kernel?: CodeExpr; guard?: CodeExpr; dynamics?: CodeExpr }; + +/** Launch passes the hangar's token through, unless the row writes another kernel. */ +const PASS_THROUGH = record({ + Airborne: { kind: "fieldAccess", target: local("input"), field: "Hangar" }, +}); + +/** + * A drone launched from a hangar into the air, its kernel, guard and + * dynamics given as trees, with a parser that looks each one up by name. + */ +function hangar({ kernel = PASS_THROUGH, guard, dynamics }: HangarCode): { + ir: PetriNetIr; + parser: CodeParser; +} { + const trees: Record = { + kernel: lambdaOf(kernel), + ...(guard === undefined ? {} : { guard: lambdaOf(guard) }), + ...(dynamics === undefined ? {} : { dynamics: dynamicsOf(dynamics) }), + }; + return { + ir: { + name: "hangar", + kind: "plain", + colours: { Drone: { battery: "real", state: { enum: ["idle", "flying"] }, id: "uuid" } }, + ...(dynamics === undefined + ? {} + : { dynamics: { Drain: { colour: "Drone", code: "dynamics" } } }), + places: { + Hangar: { + colour: "Drone", + capacity: 1, + ...(dynamics === undefined ? {} : { dynamics: "Drain" }), + }, + Airborne: { colour: "Drone", capacity: 1 }, + }, + marking: { Hangar: [{ battery: 50, state: "idle" }] }, + transitions: { + Launch: { + inputs: { Hangar: null }, + outputs: { Airborne: null }, + kernel: "kernel", + ...(guard === undefined ? {} : { guard: "guard" }), + }, + }, + }, + parser: (code) => trees[code], + }; +} + +/** One coloured place of balls, without code. */ +function pool(transitions: PetriNetIr["transitions"], marking?: PetriNetIr["marking"]): PetriNetIr { + return { + name: "pool", + kind: "plain", + colours: { Ball: { x: "real" } }, + places: { Pool: { colour: "Ball" } }, + ...(marking === undefined ? {} : { marking }), + transitions, + }; +} + +/** Each error's code and the name of its item. */ +function refused(ir: PetriNetIr, options?: Parameters[1], parser?: CodeParser) { + return compileNet(ir, options, parser).errors.map((error) => [error.code, error.item.name]); +} + +describe("the monolithic lowering on coloured places", () => { + it("refuses a document whose code it cannot read, one diagnostic per item", () => { + // GIVEN a coloured net with dynamics, a rate and a guard as code, and a plain net with a guard + const coloured: PetriNetIr = { + name: "coloured", + kind: "mixed", + colours: { Drone: { battery: "real" } }, + dynamics: { + Drain: { + colour: "Drone", + code: "return tokens.map(() => ({ battery: -1 }));", + }, + }, + places: { Hangar: { colour: "Drone", dynamics: "Drain" }, Count: null }, + transitions: { + Launch: { + inputs: { Hangar: null }, + outputs: { Count: null }, + rate: "return input.Hangar[0].battery;", + }, + Reset: { inputs: { Count: null }, guard: "return true;" }, + }, + }; + const guarded: PetriNetIr = { + name: "guarded", + kind: "plain", + places: { A: null }, + transitions: { Go: { inputs: { A: null }, guard: "return true;" } }, + }; + // WHEN each is compiled without a parser + const { files, errors } = compileNet(coloured); + // THEN each item with code is refused, and the plain net's guard alone stops it + expect(files).toEqual([]); + expect(errors.map((error) => [error.code, error.item.name])).toEqual([ + ["code-not-parsed", "Hangar"], + ["code-not-parsed", "Launch"], + ["code-not-parsed", "Reset"], + ]); + expect(() => pythonOf(guarded)).toThrow( + "transition Go: no code parser was given to read the guard", + ); + }); + + it("lowers a coloured place to slots with bindings in step order and end-of-step compaction", () => { + // GIVEN the bucket, whose rate reads each ball + // WHEN it is compiled with a parser + const python = pythonOf(bucketIr, {}, parseCode); + // THEN the pool is slots, bound in order and closed up at the end of the step + expect(python).toBe(bucketPython); + }); + + it("takes one Euler step per present token before the sweep and reads a place through a read arc", () => { + // GIVEN the boiler, whose tank level rises by dynamics + // WHEN it is compiled with a parser + const python = pythonOf(boilerIr, boilerOptions, parseCode); + // THEN the level steps first, and the alarm reads the tank without taking its token + expect(python).toContain( + " # dynamics first: one Euler step on every present token\n Tank_0_level = ite(Tank_0_present, Tank_0_level + 0.125 * (10.0 - Tank_0_level), Tank_0_level) # Tank: one Euler step of dt = 0.25\n", + ); + expect(python).toContain( + " fire_Alarm = Tank_0_present & (Tank_0_level >= 8.0) # Alarm: read Tank -> Alarms\n", + ); + expect(python).not.toContain("take_Alarm"); + expect(python).toContain(" Alarms = ite(fire_Alarm, Alarms + 1.0, Alarms)\n"); + }); + + it("writes kernels as per-token attributes with string codes, landing after the survivors", () => { + // GIVEN the drones, with kernels and a two-valued state + // WHEN they are compiled with a parser + const python = pythonOf(dronesIr, dronesOptions, parseCode); + // THEN states are codes, kernels write per-token attributes, + // and produced tokens land after the survivors + expect(python).toContain("Hangar_0_state = Var(REAL) # code: 0 idle, 1 flying\n"); + expect(python).toContain( + " out_Launch_Airborne_0_battery = ite(sel_Launch_0, Hangar_0_battery, ite(sel_Launch_1, Hangar_1_battery, Hangar_2_battery))\n out_Launch_Airborne_0_state = 1.0\n", + ); + expect(python).toContain( + " bind_Land_0 = ok_Land & Airborne_0_present & ((Airborne_0_battery < 20.0) | (Airborne_0_state != 1.0)) # Airborne[0]\n", + ); + expect(python).toContain("landed_Airborne = kept_Airborne\n"); + expect(python).toContain( + " Airborne_0_battery = ite(fire_Launch & (landed_Airborne == 0.0), out_Launch_Airborne_0_battery, Airborne_0_battery)\n", + ); + // Both coloured places are capped, so no produced token can overflow. + expect(python).not.toContain("overflow_"); + expect(python).toContain( + "e_Launch = Var(REAL) # exponential draw for Launch, each step: -ln(u) / dt", + ); + }); + + it("refuses only the code its parser returns no tree for", () => { + // GIVEN the drones, and a parser that reads every code string but the Land guard + const landGuard = dronesIr.transitions.Land?.guard; + const partial: CodeParser = (code, surface) => + code === landGuard ? undefined : parseCode(code, surface); + // WHEN they are compiled with it + const { files, errors } = compileNet(dronesIr, dronesOptions, partial); + // THEN the guard alone is refused, saying the parser gave no tree + expect(files).toEqual([]); + expect(errors.map((error) => [error.code, error.item.name, error.message])).toEqual([ + ["code-not-parsed", "Land", "the code parser returned no tree for the guard"], + ]); + }); + + it.each<[DiagnosticCode, string, HangarCode]>([ + ["kernel-output-shape", "Launch", { kernel: lit(1) }], + ["kernel-output-missing", "Launch", { kernel: record({}) }], + [ + "kernel-output-count", + "Launch", + { kernel: record({ Airborne: array(token("Hangar", 0), token("Hangar", 0)) }) }, + ], + [ + "kernel-attribute-missing", + "Launch", + { kernel: record({ Airborne: array(record({ battery: lit(1) })) }) }, + ], + [ + "string-code-unknown", + "Launch", + { kernel: record({ Airborne: array(record({ battery: lit(1), state: text("lost") })) }) }, + ], + [ + "attribute-not-lowerable", + "Launch", + { guard: op(tokenAttribute("Hangar", 0, "id"), "==", text("a")) }, + ], + ["dynamics-shape", "Hangar", { dynamics: lit(1) }], + ])( + "refuses with %s on %s a kernel, guard or dynamics the step cannot write", + (code, item, trees) => { + // GIVEN the hangar with one faulty kernel, guard or dynamics + const { ir, parser } = hangar(trees); + // WHEN it is compiled with a parser that reads the trees + const errors = refused(ir, {}, parser); + // THEN the item is refused with the code + expect(errors).toEqual([[code, item]]); + }, + ); + + it("refuses more tokens than slots, a coloured output without a kernel and too many combinations", () => { + // GIVEN three balls in two slots, a transition that makes balls with no kernel, + // and one that takes 8 of 16 slots, 12870 combinations + const crowded = pool( + { Drain: { inputs: { Pool: null } } }, + { Pool: [{ x: 1 }, { x: 2 }, { x: 3 }] }, + ); + const unwritten = pool({ Make: { outputs: { Pool: null } } }); + const greedy = pool({ Drain: { inputs: { Pool: { weight: 8 } } } }); + // WHEN each is compiled + const found = [ + refused(crowded, { slots: 2 }), + refused(unwritten), + refused(greedy, { slots: 16 }), + ]; + // THEN each is refused on its place or transition + expect(found).toEqual([ + [["marking-exceeds-slots", "Pool"]], + [["kernel-missing", "Make"]], + [["binding-explosion", "Drain"]], + ]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/monolithic/compaction.ts b/pocs/zeroth-playground/compiler/lower/monolithic/compaction.ts new file mode 100644 index 00000000..09a4e957 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic/compaction.ts @@ -0,0 +1,142 @@ +import { + assign, + binary, + ite, + num, + type LinearExpr, + type LinearStatement, + ref, +} from "../../graph/linear-graph"; +import { + attributeName, + keptName, + landedName, + nextName, + overflowName, + presentName, + rankName, +} from "../names"; + +import type { PlaceLayout } from "../colour-layout"; + +/** + * The end of a step for a coloured place, as a token list behaves: the + * survivors of the sweep close up in slot order, and the produced tokens + * land after them in sweep order. A token that finds no free slot sets the + * place's sticky overflow flag and is dropped. + */ + +export type Producer = { + /** The producer fires this step; `null` when it always does. */ + fire: LinearExpr | null; + /** Per produced token, the attribute values in layout order. */ + tokens: LinearExpr[][]; +}; + +function when(fire: LinearExpr | null, condition: LinearExpr): LinearExpr { + return fire === null ? condition : binary("&", fire, condition); +} + +export function lowerCompaction( + layout: PlaceLayout, + producers: Producer[], +): { statements: LinearStatement[]; overflows: boolean } { + const { place, slots } = layout; + const statements: LinearStatement[] = []; + function present(slot: number): LinearExpr { + return ref(presentName(place, slot)); + } + function attribute(slot: number, name: string): LinearExpr { + return ref(attributeName(place, slot, name)); + } + + // rank_j: present slots below j; kept: all of them. + statements.push({ + kind: "comment", + text: `${place}: survivors close up in slot order`, + }); + for (let slot = 1; slot <= slots; slot++) { + const indicator = ite(present(slot - 1), num(1), num(0)); + statements.push( + assign( + slot === slots ? keptName(place) : rankName(place, slot), + slot === 1 ? indicator : binary("+", ref(rankName(place, slot - 1)), indicator), + ), + ); + } + // Slot k takes the k-th survivor: the first j >= k that is present with rank j == k. + for (const attr of layout.attributes) { + for (let target = 0; target < slots; target++) { + let chain: LinearExpr = attribute(target, attr.name); + for (let source = slots - 1; source > target; source--) { + chain = ite( + binary("&", present(source), binary("==", ref(rankName(place, source)), num(target))), + attribute(source, attr.name), + chain, + ); + } + statements.push(assign(nextName(attributeName(place, target, attr.name)), chain)); + } + } + for (const attr of layout.attributes) { + for (let slot = 0; slot < slots; slot++) { + const variable = attributeName(place, slot, attr.name); + statements.push(assign(variable, ref(nextName(variable)))); + } + } + for (let slot = 0; slot < slots; slot++) { + statements.push( + assign(presentName(place, slot), binary(">=", ref(keptName(place)), num(slot + 1))), + ); + } + + // Produced tokens land after the survivors, in sweep order. + let overflows = false; + if (producers.length > 0) { + statements.push({ + kind: "comment", + text: `${place}: produced tokens land after the survivors`, + }); + statements.push(assign(landedName(place), ref(keptName(place)))); + } + for (const producer of producers) { + for (const token of producer.tokens) { + for (let slot = 0; slot < slots; slot++) { + const lands = when(producer.fire, binary("==", ref(landedName(place)), num(slot))); + layout.attributes.forEach((attr, index) => { + const variable = attributeName(place, slot, attr.name); + statements.push( + assign(variable, ite(lands, token[index] ?? ref(variable), ref(variable))), + ); + }); + statements.push(assign(presentName(place, slot), binary("|", present(slot), lands))); + } + if (!layout.capped) { + overflows = true; + statements.push( + assign( + overflowName(place), + binary( + "|", + ref(overflowName(place)), + when(producer.fire, binary(">=", ref(landedName(place)), num(slots))), + ), + ), + ); + } + statements.push( + assign( + landedName(place), + producer.fire === null + ? binary("+", ref(landedName(place)), num(1)) + : ite( + producer.fire, + binary("+", ref(landedName(place)), num(1)), + ref(landedName(place)), + ), + ), + ); + } + } + return { statements, overflows }; +} diff --git a/pocs/zeroth-playground/compiler/lower/monolithic/dynamics.ts b/pocs/zeroth-playground/compiler/lower/monolithic/dynamics.ts new file mode 100644 index 00000000..6c5bcc56 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic/dynamics.ts @@ -0,0 +1,91 @@ +import { assign, binary, ite, type LinearStatement, ref } from "../../graph/linear-graph"; +import { type PlaceLayout, slotToken } from "../colour-layout"; +import { refuse, within } from "../../diagnostics"; +import { + type LinearCodeEnv, + scaled, + type TokenBinding, + type Translated, + translateValue, +} from "../../code/linear-code"; +import { attributeName, presentName } from "../names"; +import { peelRootBindings } from "./root-bindings"; + +import type { CodeExpr, CodeFunction } from "../../code/code-tree"; + +/** + * A place's dynamics as one Euler step per slot, before the sweep: + * `x += f(x) * dt` on every real attribute of every present token. The equation's body is `tokens.map((token) => ({ attr: + * derivative, ... }))`; a missing attribute has a zero derivative. + */ + +export function lowerDynamics( + layout: PlaceLayout, + fn: CodeFunction, + dt: number, +): LinearStatement[] { + const inputName = fn.params[0]?.name ?? "tokens"; + const rootLocals = new Map(); + const env: LinearCodeEnv = { + inputName, + token: () => undefined, + tokenCount: () => undefined, + }; + const body = peelRootBindings(fn.body, env, rootLocals, "dynamics"); + // The callback may bind consts before its record; they are read per slot. + let record = body.kind === "arrayMap" ? body.body : body; + const callbackBindings: { name: string; value: CodeExpr }[] = []; + while (record.kind === "let") { + callbackBindings.push(...record.bindings); + record = record.body; + } + if ( + body.kind !== "arrayMap" || + body.target.kind !== "localRef" || + body.target.name !== inputName || + record.kind !== "recordLit" + ) { + return refuse("dynamics-shape", "dynamics map the tokens to a record of derivatives"); + } + const statements: LinearStatement[] = []; + for (let slot = 0; slot < layout.slots; slot++) { + const locals = new Map(rootLocals); + locals.set(body.param.name, slotToken(layout, slot)); + for (const binding of callbackBindings) { + locals.set( + binding.name, + within("dynamics", () => translateValue(binding.value, env, locals)), + ); + } + for (const attribute of layout.attributes) { + if (!attribute.integrates) { + continue; + } + const entry = record.entries.find((candidate) => candidate.key === attribute.name); + if (entry === undefined) { + continue; + } + const derivative = within("dynamics", () => translateValue(entry.value, env, locals)); + if (derivative.sort !== "number") { + return refuse("sort-mismatch", `the derivative of ${attribute.name} is not a number`); + } + if (derivative.expr.kind === "num" && derivative.expr.value === 0) { + // A zero derivative moves nothing. + continue; + } + const variable = attributeName(layout.place, slot, attribute.name); + statements.push( + assign( + variable, + ite( + ref(presentName(layout.place, slot)), + binary("+", ref(variable), scaled(dt, derivative.expr)), + ref(variable), + ), + slot === 0 ? `${layout.place}: one Euler step of dt = ${dt}` : undefined, + ), + ); + } + } + return statements; +} diff --git a/pocs/zeroth-playground/compiler/lower/monolithic/kernels.ts b/pocs/zeroth-playground/compiler/lower/monolithic/kernels.ts new file mode 100644 index 00000000..352d5605 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic/kernels.ts @@ -0,0 +1,165 @@ +import { assign, num, type LinearExpr, type LinearStatement, ref } from "../../graph/linear-graph"; +import { refuse, within } from "../../diagnostics"; +import { + type LinearCodeEnv, + resolveTokenExpr, + type TokenBinding, + type Translated, + translateValue, +} from "../../code/linear-code"; +import { outName } from "../names"; +import { inputTokenCount } from "../step-plan"; +import { peelRootBindings } from "./root-bindings"; + +import type { CodeExpr, CodeFunction } from "../../code/code-tree"; +import type { PlaceLayout } from "../colour-layout"; +import type { PlannedTransition, StepPlan } from "../step-plan"; + +/** + * A kernel as per-token expressions: for each coloured output arc and each + * of the `weight` tokens it produces, one local per attribute, computed in + * the sweep from the bound tokens and the draws, and landed at the end of + * the step by the compaction pass. + */ + +export type ProducedTokens = { + /** Per place, per produced token, the attribute values in layout order. */ + outs: Map; + statements: LinearStatement[]; +}; + +type Locals = Map; + +/** Peels the root `const` bindings into locals and returns the result record. */ +function resultRecord( + fn: CodeFunction, + env: LinearCodeEnv, + locals: Locals, +): Extract { + const body = peelRootBindings(fn.body, env, locals, "kernel"); + if (body.kind !== "recordLit") { + return refuse("kernel-output-shape", "a kernel returns a record keyed by output place"); + } + return body; +} + +/** The value written to one attribute of one produced token. */ +function attributeWrite( + layout: PlaceLayout, + attribute: PlaceLayout["attributes"][number], + token: CodeExpr | TokenBinding, + env: LinearCodeEnv, + locals: Locals, +): LinearExpr { + if ("attribute" in token) { + const value = token.attribute(attribute.name); + if (value === undefined || "refused" in value) { + return refuse( + "kernel-attribute-missing", + `the copied token has no ${attribute.name} the theories hold`, + ); + } + return value.expr; + } + if (token.kind !== "recordLit") { + return refuse("kernel-output-shape", "a produced token is a record or a bound token"); + } + const entry = token.entries.find((candidate) => candidate.key === attribute.name); + if (entry === undefined) { + return refuse( + "kernel-attribute-missing", + `the produced ${layout.colour} has no ${attribute.name}`, + ); + } + if (attribute.codes !== undefined) { + if (entry.value.kind === "stringLit") { + const code = attribute.codes.indexOf(entry.value.value); + return code === -1 + ? refuse("string-code-unknown", `${entry.value.value} is not a value of ${attribute.name}`) + : num(code); + } + const copied = within("kernel", () => translateValue(entry.value, env, locals)); + return copied.sort === "string" + ? copied.expr + : refuse("sort-mismatch", `${attribute.name} takes a string`); + } + const value = within("kernel", () => translateValue(entry.value, env, locals)); + const expected = attribute.sort === "bool" ? "boolean" : "number"; + return value.sort === expected + ? value.expr + : refuse( + "sort-mismatch", + `${attribute.name} takes a ${expected}, and the kernel writes a ${value.sort}`, + ); +} + +export function lowerKernel( + plan: StepPlan, + transition: PlannedTransition, + fn: CodeFunction, + boundToken: (place: string, index: number) => TokenBinding | undefined, + sample: LinearCodeEnv["sample"], +): ProducedTokens { + const env: LinearCodeEnv = { + inputName: fn.params[0]?.name ?? "input", + token: boundToken, + tokenCount: inputTokenCount(transition), + sample, + }; + const locals: Locals = new Map(); + const record = resultRecord(fn, env, locals); + const outs = new Map(); + const statements: LinearStatement[] = []; + for (const [place, weight] of transition.produces) { + const layout = plan.layouts.get(place); + if (layout === undefined) { + continue; + } + const entry = record.entries.find((candidate) => candidate.key === place); + if (entry === undefined) { + return refuse("kernel-output-missing", `the kernel writes nothing into ${place}`); + } + let tokens: (CodeExpr | TokenBinding)[]; + if (entry.value.kind === "arrayLit") { + tokens = entry.value.elements.map( + (element) => resolveTokenExpr(element, env, locals) ?? element, + ); + } else { + // A whole input place passed through: its bound tokens, in order. + const source = + entry.value.kind === "fieldAccess" && + entry.value.target.kind === "localRef" && + entry.value.target.name === env.inputName + ? entry.value.field + : undefined; + const count = source === undefined ? undefined : env.tokenCount(source); + if (source === undefined || count === undefined) { + return refuse( + "kernel-output-shape", + `the tokens for ${place} are neither listed nor an input place passed through`, + ); + } + tokens = Array.from({ length: count }, (_, index) => { + const token = boundToken(source, index); + return token ?? refuse("kernel-output-shape", `${source} is not a bound input`); + }); + } + if (tokens.length !== weight) { + return refuse( + "kernel-output-count", + `${place} receives ${tokens.length} tokens from the kernel and ${weight} from the arc`, + ); + } + const produced: LinearExpr[][] = []; + tokens.forEach((token, index) => { + const values = layout.attributes.map((attribute) => { + const name = outName(transition.name, place, index, attribute.name); + statements.push(assign(name, attributeWrite(layout, attribute, token, env, locals))); + return ref(name); + }); + produced.push(values); + }); + outs.set(place, produced); + } + return { outs, statements }; +} diff --git a/pocs/zeroth-playground/compiler/lower/monolithic/root-bindings.ts b/pocs/zeroth-playground/compiler/lower/monolithic/root-bindings.ts new file mode 100644 index 00000000..43554929 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/monolithic/root-bindings.ts @@ -0,0 +1,35 @@ +import { within } from "../../diagnostics"; +import { + type LinearCodeEnv, + resolveTokenExpr, + type TokenBinding, + type Translated, + translateValue, +} from "../../code/linear-code"; + +import type { CodeExpr } from "../../code/code-tree"; + +/** + * Peels the `const` bindings at the root of a kernel or dynamics body into + * `locals`, a bound token as itself and anything else translated, and + * returns the expression under them. + */ +export function peelRootBindings( + body: CodeExpr, + env: LinearCodeEnv, + locals: Map, + surface: "kernel" | "dynamics", +): CodeExpr { + let rest = body; + while (rest.kind === "let") { + for (const binding of rest.bindings) { + locals.set( + binding.name, + resolveTokenExpr(binding.value, env, locals) ?? + within(surface, () => translateValue(binding.value, env, locals)), + ); + } + rest = rest.body; + } + return rest; +} diff --git a/pocs/zeroth-playground/compiler/lower/names.test.ts b/pocs/zeroth-playground/compiler/lower/names.test.ts new file mode 100644 index 00000000..4d48c93d --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/names.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; + +import { itemLabel } from "../ir/net-item"; +import { birthDeathIr, birthDeathOptions } from "../testing/birth-death.fixtures"; +import { + boilerIr, + boilerOptions, + bucketIr, + dronesIr, + dronesOptions, +} from "../testing/coloured-nets.fixtures"; +import { compileNet } from "../testing/compile-net"; +import { parseCode } from "../testing/fixture-parser"; +import { forkClockedIr, forkClockedOptions } from "../testing/fork.fixtures"; +import { capacity, conflict, cycle, queue } from "../testing/small-nets.fixtures"; +import { + attributeName, + availableName, + bindName, + choiceName, + clockName, + describeName, + drawName, + enabledName, + eventName, + exponentialDrawName, + fillName, + firedName, + fireName, + firesName, + hitName, + keptName, + kernelDrawName, + landedName, + nextName, + outName, + overflowName, + pickName, + presentName, + rankName, + seenName, + selectName, + takeName, + timeReference, +} from "./names"; + +import type { LinearModule } from "../graph/linear-graph"; +import type { ModuleGraph } from "../graph/module-graph"; +import type { SpnModule } from "../graph/spn-graph"; +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** Every name a graph declares or binds: its variables and each module's locals. */ +function namesOf(graph: ModuleGraph): string[] { + const modules: (LinearModule | SpnModule)[] = graph.modules; + return [ + ...graph.variables.map((variable) => variable.name), + ...modules.flatMap((module) => + module.next.flatMap((statement) => (statement.kind === "assign" ? [statement.target] : [])), + ), + ]; +} + +/** The fixture nets, each under the options that coin the most names. */ +const COMPILED: [PetriNetIr, CompilerOptions][] = [ + [cycle, {}], + [cycle, { shape: "modular" }], + [capacity, {}], + [capacity, { shape: "modular" }], + [queue, {}], + [queue, { marking: "int" }], + [queue, { shape: "modular", marking: "int" }], + [conflict, { control: "open", conflicts: "nondet" }], + [conflict, { shape: "modular", control: "open", conflicts: "nondet" }], + [forkClockedIr, forkClockedOptions], + [birthDeathIr, birthDeathOptions], + [bucketIr, {}], + [boilerIr, boilerOptions], + [dronesIr, dronesOptions], +]; + +describe("the coined names", () => { + it("reads the coined prefixes back into words with their item", () => { + // GIVEN names the monolithic lowering coins for firings, places, slots and draws + const names = [ + "fire_Go", + "avail_Pool", + "take_Launch_Hangar_2", + "out_Launch_Air_0_battery", + "Hangar_1_present", + "Hangar_1_battery", + "z_Launch_0", + "out_Launch_Air_0_arrival_time", + "next_Hangar_2_present", + ]; + // WHEN each is read back + const [fire, avail, take, out, present, attribute, draw, outWithUnderscore, next] = names.map( + (name) => describeName(name), + ); + // THEN each says what it holds, with the item it belongs to + expect(fire).toMatchObject({ + what: "Go fires this step", + source: { kind: "transition", name: "Go" }, + }); + expect(avail).toMatchObject({ + what: "Pool's tokens after the transitions swept so far", + source: { kind: "place", name: "Pool" }, + }); + expect(take).toMatchObject({ + what: "Launch takes the token in slot 2 of Hangar", + source: { kind: "transition", name: "Launch" }, + }); + expect(out).toMatchObject({ + what: "battery of token 0 that Launch produces into Air", + }); + expect(present).toMatchObject({ + what: "Slot 1 of Hangar holds a token", + source: { kind: "place", name: "Hangar" }, + }); + expect(attribute).toMatchObject({ + what: "battery of the token in slot 1 of Hangar", + }); + expect(draw?.what).toBe("Gaussian draw 0 of Launch's kernel"); + expect(outWithUnderscore?.what).toBe("arrival_time of token 0 that Launch produces into Air"); + expect(next).toMatchObject({ + what: "Hangar_2_present once the survivors closed up", + source: { kind: "place", name: "Hangar" }, + }); + }); + + it("reads the clock strategy's names and the bare time reference", () => { + // GIVEN the time reference, the clock strategy's names, a pick and a rate draw + const names = ["t", "clk_Birth", "ev_Death", "pick_Go", "fires_Birth", "fired_Death", "e_Go"]; + // WHEN each is read back + const [time, clock, event, pick, fires, fired, rateDraw] = names.map((name) => + describeName(name), + ); + // THEN each says what it holds, and why when the name alone does not + expect(time).toEqual({ + what: "The time reference", + why: "External and driven by nothing: a clock's flow is a rate against d(t), so a module that reads it awaits t.", + }); + expect(clock).toMatchObject({ + what: "Time left until Birth fires", + source: { kind: "transition", name: "Birth" }, + }); + expect(event).toMatchObject({ + what: "Toggles when Death fires", + source: { kind: "transition", name: "Death" }, + }); + expect(pick).toEqual({ + what: "The environment lets Go fire this step", + why: "An input nothing drives: any resolution of the conflict is a run, and a proof ranges over all of them.", + source: { kind: "transition", name: "Go" }, + }); + expect(fires).toEqual({ + what: "Birth's clock ran out and its arcs allow it", + source: { kind: "transition", name: "Birth" }, + }); + expect(fired).toMatchObject({ + what: "Death fired this step", + source: { kind: "transition", name: "Death" }, + }); + expect(rateDraw?.what).toBe("Exponential draw for Go's token-dependent rate"); + }); + + it("reads back every name the fixture graphs declare or bind, apart from IR names", () => { + // GIVEN the fixture nets compiled under options that coin draws, picks, choices, clocks, slots and bindings + const unread = COMPILED.flatMap(([ir, options]) => { + const { graph } = compileNet(ir, options, parseCode); + const irNames = new Set([...Object.keys(ir.places), ...Object.keys(ir.transitions)]); + // WHEN each coined name is read back + return graph === null + ? [`${ir.name} did not compile`] + : namesOf(graph).filter((name) => !irNames.has(name) && describeName(name) === null); + }); + // THEN none is left unread + expect(unread).toEqual([]); + }); + + it("gives each coin function a prefix of its own, read back with its item", () => { + // GIVEN one name from each coin function + const coined = [ + [fireName("Go"), "transition Go"], + [drawName("Go"), "transition Go"], + [hitName("Go"), "transition Go"], + [choiceName("Go"), "transition Go"], + [pickName("Go"), "transition Go"], + [fillName("Pool"), "place Pool"], + [availableName("Pool"), "place Pool"], + [exponentialDrawName("Go"), "transition Go"], + [kernelDrawName("Go", "z", 0), "transition Go"], + [kernelDrawName("Go", "v", 1), "transition Go"], + [enabledName("Go"), "transition Go"], + [bindName("Go", 0), "transition Go"], + [selectName("Go", 0), "transition Go"], + [seenName("Go"), "transition Go"], + [takeName("Go", "Pool", 1), "transition Go"], + [outName("Go", "Pool", 0, "size"), "transition Go"], + [rankName("Pool", 1), "place Pool"], + [keptName("Pool"), "place Pool"], + [landedName("Pool"), "place Pool"], + [nextName(attributeName("Pool", 1, "size")), "place Pool"], + [overflowName("Pool"), "place Pool"], + [clockName("Go"), "transition Go"], + [eventName("Go"), "transition Go"], + [firesName("Go"), "transition Go"], + [firedName("Go"), "transition Go"], + ] as const; + // WHEN each is read back + const read = coined.map(([name]) => { + const source = describeName(name)?.source; + return source === undefined ? "unread" : itemLabel(source); + }); + // THEN each names its item, and no two functions share a prefix + expect(read).toEqual(coined.map(([, item]) => item)); + const prefixes = coined.map(([name]) => name.split("_")[0]); + expect(new Set(prefixes).size).toBe(prefixes.length); + expect(describeName(presentName("Pool", 0))?.source).toEqual({ kind: "place", name: "Pool" }); + expect(describeName(timeReference)?.what).toBe("The time reference"); + }); + + it("returns null for a name the lowerings do not coin", () => { + // GIVEN a place name and the system instance + const names = ["Waiting", "net"]; + // WHEN each is read back + const read = names.map((name) => describeName(name)); + // THEN neither has a description + expect(read[0]).toBeNull(); + expect(read[1]).toBeNull(); + }); +}); diff --git a/pocs/zeroth-playground/compiler/lower/names.ts b/pocs/zeroth-playground/compiler/lower/names.ts new file mode 100644 index 00000000..fb7fbb22 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/names.ts @@ -0,0 +1,408 @@ +import { netClassName } from "../ir/reserved-names"; + +import type { NetItem } from "../ir/net-item"; + +/** + * The identifiers the lowerings coin, and how each reads back into words. + * + * A coined identifier is a prefix and its parts joined by underscores: + * `fire_Go`, `take_Launch_Hangar_2`. An IR name is UpperCamelCase and + * carries no underscore, so a coined name cannot collide with one, and the + * prefix and the parts split without ambiguity. An attribute name is the + * user's own and may carry underscores, so it is always the last part and + * takes the rest. Two coined names have no prefix: a slot of a coloured + * place (`Hangar_1_present`, `Hangar_1_battery`), which starts with its + * place, and the time reference `t`, a bare lowercase letter. + * + * Every prefix has one entry in `PREFIXES`, its reading; a coin function + * can only use a prefix that has one. + */ + +/** A coined identifier read back: what it holds, why when the name alone does not say, and its item. */ +export type NameReading = { + what: string; + why?: string; + source?: NetItem; +}; + +type Prefixed = { + /** The kind of item the first part names; absent when the reading finds its own. */ + item?: "transition" | "place"; + /** The reading, from the parts after the prefix. */ + read(parts: readonly string[]): NameReading; +}; + +const PREFIXES = { + fire: { + item: "transition", + read: ([transition]) => ({ + what: `${transition} fires this step`, + why: "The transition's module drives it; the places it touches await it.", + }), + }, + u: { + item: "transition", + read: ([transition]) => ({ + what: `Uniform draw for ${transition}, each step`, + why: "An input the harness writes; the transition fires when the draw is at least e^(-rate·dt).", + }), + }, + hit: { + item: "transition", + read: ([transition]) => ({ + what: `${transition}'s draw passed its threshold`, + why: "Driven by the transition's Draw module, so the places stay Int.", + }), + }, + go: { + item: "transition", + read: ([transition]) => ({ + what: `External choice for ${transition}`, + why: "A controllable transition also waits for a controller to choose it.", + }), + }, + pick: { + item: "transition", + read: ([transition]) => ({ + what: `The environment lets ${transition} fire this step`, + why: "An input nothing drives: any resolution of the conflict is a run, and a proof ranges over all of them.", + }), + }, + fill: { + item: "place", + read: ([place]) => ({ + what: `${place}'s tokens if the step ended now`, + why: "A capped place: a producer checks the room left before adding.", + }), + }, + avail: { + item: "place", + read: ([place]) => ({ + what: `${place}'s tokens after the transitions swept so far`, + why: "A later transition sees what the earlier ones took.", + }), + }, + e: { + item: "transition", + read: ([transition]) => ({ + what: `Exponential draw for ${transition}'s token-dependent rate`, + why: "-ln(u) / dt, compared with the rate the tokens give.", + }), + }, + z: { + item: "transition", + read: ([transition, index]) => ({ + what: `Gaussian draw ${index ?? ""} of ${transition}'s kernel`, + why: "A kernel's distribution with a constant spread becomes an input the harness draws.", + }), + }, + v: { + item: "transition", + read: ([transition, index]) => ({ + what: `Uniform draw ${index ?? ""} of ${transition}'s kernel`, + why: "A kernel's distribution with a constant spread becomes an input the harness draws.", + }), + }, + ok: { + item: "transition", + read: ([transition]) => ({ + what: `${transition} is structurally enabled`, + why: "Its input places hold enough tokens, before any guard.", + }), + }, + bind: { + item: "transition", + read: ([transition, index]) => ({ + what: `Combination ${index ?? ""} of ${transition}'s tokens passes its guard`, + }), + }, + sel: { + item: "transition", + read: ([transition, index]) => ({ + what: `Combination ${index ?? ""} is the first of ${transition}'s that passes`, + why: "Combinations are tried in binding order, the last arc fastest, and the first passing one fires.", + }), + }, + seen: { + item: "transition", + read: ([transition]) => ({ what: `Some combination of ${transition} up to here passed` }), + }, + take: { + item: "transition", + read: ([transition, place, slot]) => ({ + what: `${transition} takes the token in slot ${slot ?? ""} of ${place ?? ""}`, + }), + }, + out: { + item: "transition", + read: ([transition, place, index, ...attribute]) => ({ + what: `${attribute.join("_")} of token ${index ?? ""} that ${transition} produces into ${place ?? ""}`, + }), + }, + rank: { + item: "place", + read: ([place, slot]) => ({ + what: `Present slots of ${place} below slot ${slot ?? ""}, once the sweep is done`, + why: "Survivors close up in slot order; a token's rank is its landing slot.", + }), + }, + kept: { + item: "place", + read: ([place]) => ({ what: `Tokens ${place} keeps after the sweep` }), + }, + landed: { + item: "place", + read: ([place]) => ({ what: `The slot the next token produced into ${place} lands in` }), + }, + next: { + read: (parts) => { + const variable = parts.join("_"); + const source = describeName(variable)?.source; + return { + what: `${variable} once the survivors closed up`, + ...(source === undefined ? {} : { source }), + }; + }, + }, + overflow: { + item: "place", + read: ([place]) => ({ + what: `A token produced into ${place} found no free slot`, + why: "The place has more tokens than slots; raise its capacity or the slots option.", + }), + }, + clk: { + item: "transition", + read: ([transition]) => ({ + what: `Time left until ${transition} fires`, + why: "Armed with exp(rate) when the transition fires, run down against t while its arcs allow it; hidden in the composition.", + }), + }, + ev: { + item: "transition", + read: ([transition]) => ({ + what: `Toggles when ${transition} fires`, + why: "An event fires by changing value; a place reads it with fired().", + }), + }, + fires: { + item: "transition", + read: ([transition]) => ({ what: `${transition}'s clock ran out and its arcs allow it` }), + }, + fired: { + item: "transition", + read: ([transition]) => ({ + what: `${transition} fired this step`, + why: "Its event differs between the latched value and the next.", + }), + }, +} satisfies Record; + +type Prefix = keyof typeof PREFIXES; + +function coin(prefix: Prefix, ...parts: (string | number)[]): string { + return [prefix, ...parts].join("_"); +} + +export function fireName(transition: string): string { + return coin("fire", transition); +} + +export function drawName(transition: string): string { + return coin("u", transition); +} + +export function hitName(transition: string): string { + return coin("hit", transition); +} + +export function choiceName(transition: string): string { + return coin("go", transition); +} + +/** The environment's pick a transition in a conflict waits for, under `conflicts: nondet`. */ +export function pickName(transition: string): string { + return coin("pick", transition); +} + +export function fillName(place: string): string { + return coin("fill", place); +} + +export function availableName(place: string): string { + return coin("avail", place); +} + +/** The exponential draw a token-dependent rate is tested against: `-ln(u) / dt`. */ +export function exponentialDrawName(transition: string): string { + return coin("e", transition); +} + +/** The k-th standard-normal or uniform draw a kernel takes. */ +export function kernelDrawName(transition: string, kind: "z" | "v", index: number): string { + return coin(kind, transition, index); +} + +export function enabledName(transition: string): string { + return coin("ok", transition); +} + +export function bindName(transition: string, index: number): string { + return coin("bind", transition, index); +} + +export function selectName(transition: string, index: number): string { + return coin("sel", transition, index); +} + +export function seenName(transition: string): string { + return coin("seen", transition); +} + +export function takeName(transition: string, place: string, slot: number): string { + return coin("take", transition, place, slot); +} + +export function outName( + transition: string, + place: string, + index: number, + attribute: string, +): string { + return coin("out", transition, place, index, attribute); +} + +export function rankName(place: string, slot: number): string { + return coin("rank", place, slot); +} + +export function keptName(place: string): string { + return coin("kept", place); +} + +export function landedName(place: string): string { + return coin("landed", place); +} + +export function nextName(variable: string): string { + return coin("next", variable); +} + +export function overflowName(place: string): string { + return coin("overflow", place); +} + +export function clockName(transition: string): string { + return coin("clk", transition); +} + +export function eventName(transition: string): string { + return coin("ev", transition); +} + +export function firesName(transition: string): string { + return coin("fires", transition); +} + +export function firedName(transition: string): string { + return coin("fired", transition); +} + +/** A slot of a coloured place holds a token. */ +export function presentName(place: string, slot: number): string { + return `${place}_${slot}_present`; +} + +/** One attribute of the token in a slot. */ +export function attributeName(place: string, slot: number, attribute: string): string { + return `${place}_${slot}_${attribute}`; +} + +const SLOT = /^([A-Z][A-Za-z0-9]*)_(\d+)_([A-Za-z_][\w]*)$/u; + +function readSlot(place: string, slot: string, attribute: string): NameReading { + const source: NetItem = { kind: "place", name: place }; + return attribute === "present" + ? { + what: `Slot ${slot} of ${place} holds a token`, + why: "A coloured place is a fixed row of slots, each with a present flag and one variable per attribute.", + source, + } + : { what: `${attribute} of the token in slot ${slot} of ${place}`, source }; +} + +/** The external time reference every clock runs down against. */ +export const timeReference = "t"; + +const TIME_READING: NameReading = { + what: "The time reference", + why: "External and driven by nothing: a clock's flow is a rate against d(t), so a module that reads it awaits t.", +}; + +function isPrefix(prefix: string): prefix is Prefix { + return Object.hasOwn(PREFIXES, prefix); +} + +/** A coined identifier read back into words, or `null` for a name the lowerings do not coin. */ +export function describeName(name: string): NameReading | null { + if (name === timeReference) { + return TIME_READING; + } + const slot = SLOT.exec(name); + if (slot !== null) { + const [, place = "", index = "", attribute = ""] = slot; + return readSlot(place, index, attribute); + } + const [prefix = "", ...parts] = name.split("_"); + const [first] = parts; + if (first === undefined || !isPrefix(prefix)) { + return null; + } + const family: Prefixed = PREFIXES[prefix]; + const reading = family.read(parts); + return family.item === undefined + ? reading + : { ...reading, source: { kind: family.item, name: first } }; +} + +/** A module's class, its instance, and the net item it stands for. */ +export type ModuleNames = { + className: string; + instance: string; + source: NetItem; + draw?: true; +}; + +export function transitionModuleNames(transition: string): ModuleNames { + return { + className: `Transition_${transition}`, + instance: `transition_${transition}`, + source: { kind: "transition", name: transition }, + }; +} + +export function placeModuleNames(place: string): ModuleNames { + return { + className: `Place_${place}`, + instance: `place_${place}`, + source: { kind: "place", name: place }, + }; +} + +export function drawModuleNames(transition: string): ModuleNames { + return { + className: `Draw_${transition}`, + instance: `draw_${transition}`, + source: { kind: "transition", name: transition }, + draw: true, + }; +} + +/** The whole net's module; its instance is named when it is composed with others. */ +export function netModuleNames(netName: string): ModuleNames { + return { + className: netClassName(netName), + instance: "marking", + source: { kind: "net", name: netName }, + }; +} diff --git a/pocs/zeroth-playground/compiler/lower/step-plan.ts b/pocs/zeroth-playground/compiler/lower/step-plan.ts new file mode 100644 index 00000000..662cb624 --- /dev/null +++ b/pocs/zeroth-playground/compiler/lower/step-plan.ts @@ -0,0 +1,238 @@ +import { arcKind, arcWeight, conflictingTransitions, initialTokens } from "../ir/accessors"; +import { netFacts } from "../options"; +import { layoutColouredPlaces, type PlaceLayout } from "./colour-layout"; + +import type { CodeFunction, CodeParser, CodeSurface } from "../code/code-tree"; +import type { Diagnostic } from "../diagnostics"; +import type { PetriNetIr, PetriNetIrToken, PetriNetIrTransition } from "../ir/schema"; +import type { NetFacts, ResolvedOptions } from "../options"; + +/** + * One step of the net, read off the IR once for every shape: which places + * each transition takes from and adds to, the net change it makes, the + * threshold its draw is tested against, the code it runs, how each coloured + * place lays out its tokens, and which capped places need their pending + * tokens tracked. + */ + +/** The tokens a transition moves per place, in arc order. */ +export type PlannedArcs = [place: string, weight: number][]; + +export type PlannedArc = { + place: string; + weight: number; + kind: "standard" | "read" | "inhibitor"; +}; + +export type PlannedTransition = { + name: string; + /** `Go: A -> B`, the transition as a reader sees it. */ + description: string; + /** Every input arc, in binding order. */ + inputArcs: PlannedArc[]; + /** Standard input arcs summed per place. */ + consumes: PlannedArcs; + produces: PlannedArcs; + /** Net tokens a firing adds to each place it touches; zero entries left out. */ + deltas: Map; + /** A constant rate: mean firings per time unit while enabled. */ + rate: number | null; + /** A constant rate's test: the transition fires when its draw is at least `e^(-rate * dt)`. */ + threshold: number | null; + /** A rate computed from the input tokens. */ + rateCode: CodeFunction | null; + /** A predicate's condition over the input tokens. */ + guard: CodeFunction | null; + /** The kernel writing the produced tokens' attributes. */ + kernel: CodeFunction | null; + /** The model marks the firing as a controller's choice. */ + controllable: boolean; + /** Shares an input place with another transition. */ + conflicting: boolean; +}; + +export type PlannedPlace = { + name: string; + initial: number; + /** A coloured place's starting tokens, in the marking's order. */ + rows: PetriNetIrToken[]; + capacity: number | undefined; + /** The slots and attributes of a coloured place. */ + layout: PlaceLayout | null; + /** The equation moving its tokens between steps. */ + dynamics: CodeFunction | null; +}; + +export type StepPlan = { + /** The net's name, which names the monolithic module. */ + name: string; + /** Whether the net is stochastic, coloured or dynamic, as the options read it. */ + facts: NetFacts; + kind: PetriNetIr["kind"]; + options: ResolvedOptions; + places: PlannedPlace[]; + layouts: Map; + /** Capped places some transition produces into, with their capacity: their pending tokens count. */ + capped: Map; + transitions: PlannedTransition[]; +}; + +/** Tokens an arc side moves per place: its standard arcs, one per place. */ +function sideTotals(arcs: PetriNetIrTransition["inputs"]): PlannedArcs { + return Object.entries(arcs ?? {}) + .filter(([, arc]) => arcKind(arc) === "standard") + .map(([place, arc]) => [place, arcWeight(arc)]); +} + +function netDeltas(consumes: PlannedArcs, produces: PlannedArcs): Map { + const deltas = new Map(); + for (const [place, weight] of produces) { + deltas.set(place, (deltas.get(place) ?? 0) + weight); + } + for (const [place, weight] of consumes) { + deltas.set(place, (deltas.get(place) ?? 0) - weight); + } + for (const [place, delta] of deltas) { + if (delta === 0) { + deltas.delete(place); + } + } + return deltas; +} + +function describeSide(arcs: PlannedArcs): string { + return ( + arcs.map(([place, weight]) => (weight === 1 ? place : `${weight} ${place}`)).join(", ") || + "nothing" + ); +} + +function describeInputs(arcs: PlannedArc[]): string { + return ( + arcs + .map( + ({ place, weight, kind }) => + `${kind === "standard" ? "" : `${kind} `}${weight === 1 ? place : `${weight} ${place}`}`, + ) + .join(", ") || "nothing" + ); +} + +/** The surface each kind of code is parsed as. */ +const SURFACES = { + guard: "lambda", + rate: "lambda", + kernel: "kernel", + dynamics: "dynamics", +} as const satisfies Record; + +/** Parses one code string, reporting one that no parser reads. */ +function parsed( + code: string | undefined, + what: keyof typeof SURFACES, + parse: CodeParser | undefined, + item: Diagnostic["item"], + errors: Diagnostic[], +): CodeFunction | null { + if (code === undefined) { + return null; + } + const fn = parse?.(code, SURFACES[what]); + if (fn === undefined) { + errors.push({ + code: "code-not-parsed", + message: + parse === undefined + ? `no code parser was given to read the ${what}` + : `the code parser returned no tree for the ${what}`, + item, + }); + return null; + } + return fn; +} + +/** `input.P.length` for a transition: the weight of its standard or read arc from P. */ +export function inputTokenCount( + transition: PlannedTransition, +): (place: string) => number | undefined { + return (place) => + transition.inputArcs.find((arc) => arc.place === place && arc.kind !== "inhibitor")?.weight; +} + +export function planStep( + ir: PetriNetIr, + options: ResolvedOptions, + parse: CodeParser | undefined, + errors: Diagnostic[], +): StepPlan { + const layouts = layoutColouredPlaces(ir, options, errors); + const places: PlannedPlace[] = Object.entries(ir.places).map(([name, place]) => { + const item = { kind: "place" as const, name }; + const equation = place?.dynamics === undefined ? undefined : ir.dynamics?.[place.dynamics]; + const marking = ir.marking?.[name]; + return { + name, + initial: initialTokens(ir, name), + rows: Array.isArray(marking) ? marking : [], + capacity: place?.capacity, + layout: layouts.get(name) ?? null, + dynamics: parsed(equation?.code, "dynamics", parse, item, errors), + }; + }); + const conflicting = conflictingTransitions(ir.transitions); + const transitions: PlannedTransition[] = Object.entries(ir.transitions).map( + ([name, transition]) => { + const item = { kind: "transition" as const, name }; + const inputArcs: PlannedArc[] = Object.entries(transition.inputs ?? {}).map( + ([place, arc]) => ({ + place, + weight: arcWeight(arc), + kind: arcKind(arc), + }), + ); + const consumes = sideTotals(transition.inputs); + const produces = sideTotals(transition.outputs); + const rate = typeof transition.rate === "number" ? transition.rate : null; + return { + name, + description: `${name}: ${describeInputs(inputArcs)} -> ${describeSide(produces)}`, + inputArcs, + consumes, + produces, + deltas: netDeltas(consumes, produces), + rate, + threshold: rate === null ? null : Math.exp(-rate * options.dt), + rateCode: parsed( + typeof transition.rate === "string" ? transition.rate : undefined, + "rate", + parse, + item, + errors, + ), + guard: parsed(transition.guard, "guard", parse, item, errors), + kernel: parsed(transition.kernel, "kernel", parse, item, errors), + controllable: transition.controllable === true, + conflicting: conflicting.has(name), + }; + }, + ); + const capped = new Map( + places.flatMap((place) => + place.capacity !== undefined && + transitions.some((transition) => (transition.deltas.get(place.name) ?? 0) > 0) + ? [[place.name, place.capacity] as const] + : [], + ), + ); + return { + name: ir.name, + facts: netFacts(ir, options), + kind: ir.kind, + options, + places, + layouts, + capped, + transitions, + }; +} diff --git a/pocs/zeroth-playground/compiler/mapping.md b/pocs/zeroth-playground/compiler/mapping.md new file mode 100644 index 00000000..41963298 --- /dev/null +++ b/pocs/zeroth-playground/compiler/mapping.md @@ -0,0 +1,361 @@ +# Mapping the Petri net IR to reactive modules + +The design shared by HASH and Zeroth: how each part of a net maps to zrth, the options, the open questions and who can settle each. Update a question here when it is settled. Every mapping under coins reproduces one step of the net, as [README.md](README.md#one-step-of-the-net) defines it. + +## 1. Scope + +The Petri net IR is the YAML document `ir/schema.ts` defines; `lower/lower.ts` turns it into a module graph, and the two emitters, `emit/linear.ts` and `emit/spn.ts`, print Python over `zrth.sugar`: classes with `init`, `next` and `flow` blocks over variables, composed by awaits, over one theory. The target is Zeroth's `spn` branch at 5bd3bf9 (2026-09-28), with the theories LIA, LRA and SPN. `pnpm check:zrth` checks the output against that commit (see (i)). + +Each strategy is an option passed beside the IR, not written in it (the `OPTIONS` table in `options.ts`, which also says when each option applies), so the same IR compiles differently under different options. An option that does not apply to the net is dropped with an `option-not-applicable` warning, whose message is the reason the playground's panel shows; the net compiles as if it had not been asked for. So `rates: clock` on a coloured net warns and compiles under coins. Whether a net is stochastic is read off its rates, not its `kind`. The playground keeps only the options that apply to the net and differ from the default. + +Two rate strategies recur below. Coins test each rated transition once per step of `dt` against a uniform draw, in LIA or LRA. Clocks arm an exponential clock per transition over continuous time, in SPN. + +[README.md](README.md#options) lists the options, their values and the nets each applies to. + +## 2. The mappings + +### (a) The net + +**IR.** `name` (lower_snake from the title), `kind: plain`, `stochastic` or `mixed`, and the sections `colours`, `dynamics`, `places`, `marking` and `transitions`. The IR carries no options. The parser checks that `kind` agrees with the rates: none, one on every transition, or one on some (`kind-mismatch`), and that every place an arc or the marking names is declared (`unknown-place`). The record order of `transitions` is the sweep order: the order in which one step tries the transitions, each firing consuming its inputs at once. + +**Theory.** The theory follows the marking sort in (b): LIA for `Int`, LRA for `Real`, both under `marking: int` (an LIA step module, one LRA `Draw_T` per rated transition), SPN under clocks. + +| Option | Produces | Status | Chosen by | +| --- | --- | --- | --- | +| Monolithic | one class per net; `next` is one step of the net; `net = Cycle(theory=LIA, ctrl=(A, B))` | shipped | `shape: monolithic` | +| Modular | `Transition_T` drives `fire_T: Bool`, `Place_P` drives `P`; `compose(...)` | shipped; a coloured or dynamic net is refused (`modular-coloured-not-lowered`), before its code is read | `shape: modular` | +| Clocks | `Transition_T` drives `(clk_T, ev_T)`, `Place_P` drives `P`; `next` and `flow`; `compose(..., hide={clk_*})` | shipped | `rates: clock`; `shape` is ignored | +| One module per transition, places written by several | several drivers of one place | rejected: a variable has one driver in Zeroth | not offered | +| A `hide` option | hidden internals under coins | rejected: a hidden variable cannot be coupled again | not offered | + +**Awaits.** The linear graph is the LIA or LRA module graph (`graph/linear-graph.ts`); the SPN graph is the clocked one (`graph/spn-graph.ts`). In the linear graph an await is `X(name)`, and the await graph must be a DAG. The monolithic module awaits only its inputs. A modular transition awaits `fire_` of every earlier transition that takes from its input places, and of every earlier mover of a capped place it fills. A place awaits `fire_` of its movers. In the SPN graph the awaits are `fired(ev)` and `d(t)`. + +**Files.** `layout: per-module` writes `net.py` (variables, imports, `compose`) plus one file per class, `transition_birth.py` for `Transition_Birth`. Every file opens on its imports: the compiler writes no header naming the net or where it came from. Every module's step method is `next`; there is no option for `update`. At the spn tip `next` and `flow` alias `update` and `delay` for every theory (`_method` in `sugar.py`, `test_next_and_flow_are_aliases_for_update_and_delay`), and `origin/main` accepts `update` only. + +The cycle as IR (`examples/cycle/net.pn.yaml`) and as the monolithic step (golden "lowers a plain net to an LIA module with one step of the net per next", `lower/monolithic.test.ts`): + +```yaml +transitions: + Go: + inputs: + A: + outputs: + B: +``` + +```python + def next(self, A, B): + # sweep in order; a firing consumes its input tokens at once + fire_Go = A >= 1 # Go: A -> B + A = ite(fire_Go, A - 1, A) + fire_Back = B >= 1 # Back: B -> A + B = ite(fire_Back, B - 1, B) + # end of step: produced tokens land + A = ite(fire_Back, A + 1, A) + B = ite(fire_Go, B + 1, B) + return A, B +``` + +The modular composition (`examples/__snapshots__/cycle/net.py`) and the clocked one (`birthDeathPython`, `testing/birth-death.fixtures.ts`): + +```python +transition_Go = Transition_Go(theory=LIA, ctrl=(fire_Go,), extl=(A,)) +place_A = Place_A(theory=LIA, ctrl=(A,), extl=(fire_Go, fire_Back)) +net = compose(transition_Go, transition_Back, place_A, place_B) +``` + +```python +net = compose( + transition_Birth, + transition_Death, + place_Population, + hide={clk_Birth, clk_Death}, +) +``` + +Open questions: + +- The modular shape for coloured and dynamic nets. (HASH) +- When the spn line merges to `main`, so the `next` the compiler writes stops depending on the branch. (Zeroth) +- Whether SPN and LRA modules compose in one system. (Zeroth) + +### (b) Places + +**IR.** `places.: null` or `{ capacity?, colour?, dynamics? }`. `marking.` is a count for a plain place and a list of token records for a coloured one; an absent entry starts empty. + +| Place | Sort | Status | Chosen by | +| --- | --- | --- | --- | +| Plain net | `Int` (LIA) | shipped | default | +| Stochastic, coins | `Real` (LRA) | shipped | `marking: real` | +| Stochastic, coins | `Int`, with `Draw_T` modules in LRA | shipped | `marking: int` | +| Coloured or dynamic | `Real` per attribute per position | shipped; `marking: int` does not apply (`option-not-applicable`) | colour or dynamics | +| Clocks | `Nat` (SPN) | shipped; SPN tests a count against zero only | `rates: clock` | + +**Initial marking.** `init` returns one literal per ctrl in ctrl order. The parser refuses a count on a plain place that is not whole or is negative, a count on a coloured place or a token list on a plain one (`marking-invalid`), and a marking over the capacity (`marking-over-capacity`). So a Nat counter under clocks always starts at a whole count. + +**Capacity.** + +| Shape | Capacity | +| --- | --- | +| Monolithic | `fill_P = P` before the sweep; a producer's guard adds `fill_P + delta <= cap`; every mover updates `fill_P`; the place returns it | +| Modular | the producer rebuilds `fill_P` from `X(fire_)` of the earlier movers | +| Clocks | refused (`clocks-capacity`) | + +A complement counter of free room was rejected (an interface variable per capped place, a Nat that may be empty). + +**Coloured places.** A coloured place is a row of positions, as many as its `capacity` or the `slots` option. Each position has `P_i_present: Bool` and one variable per attribute: real and integer as `Real`, boolean as `Bool`, a closed string as a `Real` code (`# code: 0 idle, 1 flying`). A `uuid` or an open string gets no variable (`attribute-not-lowerable` where read). Present positions form a dense prefix, so `input.P[k]` is position k. At the end of the step `rank_P_j` and `kept_P` close the survivors up, and produced tokens land after them. An uncapped place with producers gets a sticky `overflow_P`. Rejected: vectors (`Ite` needs a scalar condition) and first-free landing. + +A capped place under coins (golden "tests a stochastic transition against an external draw over dt", `lower/monolithic.test.ts`): + +```python + fill_Waiting = Waiting + # sweep in order; a firing consumes its input tokens at once + fire_Arrive = (fill_Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) # Arrive: nothing -> Waiting + fill_Waiting = ite(fire_Arrive, fill_Waiting + 1.0, fill_Waiting) +``` + +Positions and compaction (`bucketPython` in `testing/coloured-nets.fixtures.ts`, `Pool: { colour: "Ball", capacity: 2 }`): + +```python +Pool_0_present = Var(BOOL) # Pool slot 0 holds a token +Pool_0_x = Var(REAL) +``` + +```python + rank_Pool_1 = ite(Pool_0_present, expr(1.0, theory=LRA, sort=REAL), 0.0) + kept_Pool = rank_Pool_1 + ite(Pool_1_present, expr(1.0, theory=LRA, sort=REAL), 0.0) + next_Pool_0_x = ite(Pool_1_present & (rank_Pool_1 == 0.0), Pool_1_x, Pool_0_x) + Pool_0_present = kept_Pool >= 1.0 +``` + +Open questions: + +- Capacities under SPN: an ordering on `Nat`, or a complement place? (Zeroth) +- Coloured tokens under SPN: a `Real` sort, or an LRA module beside SPN ones? (Zeroth) + +### (c) Transitions, enabling + +**IR.** `inputs.: null` or `{ weight?, kind?: read | inhibitor }` in binding order, `guard` as code when the condition reads tokens, and `controllable: true`. + +| Arc | Linear, plain place | Linear, coloured place | Clocks | +| --- | --- | --- | --- | +| Standard, weight w | `P >= w`, then `P - w` at once | presence terms per binding, `take_T_P_s` | `P != 0`; w > 1 is refused (`clocks-arc-weight`) | +| Read | `P >= w`, no consumption | presence terms, no `take_` | `P != 0` | +| Inhibitor | `P < w` | `count < w` | `P == 0` | + +**Guards.** A code string reaches the compiler as a code tree from the parser hook, and `linear-code.ts` translates the linear subset; [README.md](README.md#the-code-parser-hook) lists both. Bindings try the token combinations in binding order, the last arc advancing fastest: `bind_T_i` per combination, `sel_T_i` for the first match, `fire_T = seen_T`. More than 4096 combinations are refused (`binding-explosion`). Clocks refuse a guard (`clocks-guard`). + +**Controllable.** `control: open` adds the input `go_T: Bool` and `& X(go_T)` to the guard. Under clocks the option does not apply (`option-not-applicable`): the sugar has no helper for SPN's `Nondet(s)` and `simulate.py` gives it no semantics. + +An open choice (golden "opens a controllable transition to an external choice under control open", `lower/monolithic.test.ts`) and a read arc over a coloured token (golden `boiler`, `lower/monolithic/colour.test.ts`): + +```python + fire_TakeLeft = (Pool >= 2) & X(go_TakeLeft) # TakeLeft: 2 Pool -> Left +``` + +```python + fire_Alarm = Tank_0_present & (Tank_0_level >= 8.0) # Alarm: read Tank -> Alarms +``` + +Read and inhibitor arcs under clocks (golden "tests read and inhibitor arcs, applies one exclusive case per mover, and leaves an untouched place alone", `lower/clocks.test.ts`): + +```python + fires_In = (clk_In == 0) & (B != 0) & (C == 0) +``` + +Open questions: + +- Weighted arcs and output weights under SPN: `(n + 1) + 1` (an `Inc` of `Inc`, inferred from `check_nat_ops`, untested), or a count on `Inc` and `Dec`? (Zeroth) +- How an open SPN module is driven. (Zeroth) + +### (d) Transitions, firing and stochastic rates + +**IR.** `rate` is a number when the code reads no tokens (parameters baked in) and code otherwise. No `rate` means a plain transition. An infinite rate does not fit the schema (`schema`). A rate of zero or less never passes its coin test, whose threshold is then at least 1, and is refused under clocks (`clocks-rate-not-positive`). + +| Option | Produces | Status | Chosen by | +| --- | --- | --- | --- | +| Coin, constant rate | input `u_T: Real`; term `X(u_T) >= e^(-rate·dt)`, threshold computed at compile time | shipped | `rates: coin` | +| Coin, `marking: int` | `Draw_T` (LRA) drives `hit_T: Bool`; the LIA guard reads `X(hit_T)` | shipped | `marking: int` | +| Coin, rate reads tokens | input `e_T: Real`, the draw `-ln(u) / dt`; `rate >= X(e_T)` per binding | shipped | rate code | +| Plain transition (predicate or immediate) | fires every step it is enabled | shipped under coins; refused under clocks (`clocks-plain-transition`) | no `rate` | +| Clock | `clk_T = Var(Clock())` armed with `exp(rate)`, `ev_T = Var(Event())` toggled at expiry, flow `-1 * d(t)`, clocks hidden | shipped for a constant positive rate; rate code refused (`clocks-rate-code`); matches `two_place_net()` in `test_spn.py` | `rates: clock` | +| HAVOC coin per transition | a free Bool | not built | not offered | +| Fluid mean-field delay block | an LRA flow | not built; the LRA flow limits in (g) apply | not offered | + +Under clocks the compiler's transition keeps the arc terms in `fires_T` and pauses the clock while disabled. Zeroth's `birth_death.py` at 5bd3bf9 lets the clock run, and the place ignores a death on an empty place. For one input place the two agree by memorylessness. They differ for two: with `fires_Move = (clk_Move == 0) & (A != 0) & (B != 0)` (golden), the place-decides rule would let `B` consume while `A` does not, and `C` produce regardless. + +A draw module under `marking: int` (golden "keeps Int places under marking int by moving each draw test into an LRA module", `lower/modular.test.ts`): + +```python +class Draw_Arrive(Module): + """Arrive at rate 2.5 fires within a step of dt = 0.5 when its draw is at least e^(-2.5 * 0.5)""" + + def init(self, u_Arrive): + return False + + def next(self, hit_Arrive, u_Arrive): + return X(u_Arrive) >= 0.2865047968601901 +``` + +The compiler's `Transition_Death` (`testing/birth-death.fixtures.ts`) beside Zeroth's `Death` (`birth_death.py` at 5bd3bf9): + +```python + def next(self, clk_Death, ev_Death, Population, t): + fires_Death = (clk_Death == 0) & (Population != 0) + return ite(fires_Death, exp(1.0), clk_Death), ite(fires_Death, ~ev_Death, None) + + def flow(self, clk_Death, ev_Death, Population, t): + return ite(clk_Death >= 0, ite(Population != 0, -1 * d(t), 0 * d(t)), None), 0 +``` + +```python + def next(self, clk, dth, n, t): + fires = (clk == 0) + return ite(fires, exp(1.0), clk), ite(fires, ~dth, dth) + + def flow(self, clk, dth, n, t): + return ite(clk >= 0, -1 * d(t), None), 0 +``` + +Both write the partial if-then as `ite(cond, x, None)` and an event's flow as `0`. The compiler toggles the event with the partial `ite(fires, ~ev, None)`, where the event stutters when the transition does not fire; Zeroth writes the total `ite(fires, ~e, e)`. zrth accepts both. + +Open questions: + +- Guard in the transition with a paused clock, or place decides, for several input places? (HASH; Zeroth on the intent) +- Marking-dependent rates under SPN, where `Exp` takes a constant. (Zeroth) +- Immediate transitions under SPN: a zero-delay construct with priorities? (Zeroth) + +### (e) Conflicts and non-determinism + +**IR.** `conflicts` is written when two transitions share an input place, whatever the arc kinds (`conflictingTransitions`); a transition outside every conflict gets no pick. + +| Option | Produces | Status | Chosen by | +| --- | --- | --- | --- | +| Sweep | coins: the sweep order (monolithic) or `avail_P` awaits (modular); clocks: the earliest expiry ends the step | shipped; byte-equal to the output without the option | `conflicts: sweep` | +| Nondet | input `pick_T: Bool` per conflicting transition; coins `& X(pick_T)`, clocks `& pick_T` in `fires_T`; picks stay in the interface | shipped | `conflicts: nondet` | +| Free sweep: every enabled transition fires on the latched marking | several firings on one token | rejected: two transitions competing for one token both fire | not offered | + +`simulate.py` takes no external but `t` (see (i)), so the clocked nondet output does not run there. Under a driver, a false pick at expiry holds the flow horizon (`hi` in `simulate.py`) at 0. A round that then changes nothing raises `NoFlow` (`stuck at {now}: time cannot pass and the round changes nothing`). Two alternatives: pause the clock in `flow` on the pick, or re-arm it at expiry whatever the pick. `GuardSkipped` names a `next` that would change state before `hi` and not at `hi`. Every `fires_T` the compiler writes needs `clk_T == 0`, which holds only where `clk_T >= 0` stops time, so its output cannot raise it. + +Coins (golden "has each transition in a conflict wait for an undriven pick under conflicts nondet", `lower/monolithic.test.ts`) and clocks (`examples/__snapshots__/fork-clocked/net.py`): + +```python + fire_TakeLeft = (Pool >= 2) & X(pick_TakeLeft) # TakeLeft: 2 Pool -> Left +``` + +```python + fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft +``` + +```python + return ite(fired_Return & ~fired_TakeLeft & ~fired_TakeRight, Pool + 1, ite(fired_TakeLeft & ~fired_Return & ~fired_TakeRight & (Pool != 0), Pool - 1, ite(fired_TakeRight & ~fired_Return & ~fired_TakeLeft & (Pool != 0), Pool - 1, Pool))) +``` + +The interpreter reads an undriven pick as true, so the nondet trace equals the sweep trace. On a plain two-transition fork under coins (`lower/lower.test.ts`, "holds a transition whose pick is false and refuses another input left without a value"), holding `pick_TakeLeft` false for three steps ends at `{ Pool: 0, Left: 0, Right: 3 }`. + +Open questions: + +- A false pick that holds time, or a pause of the clock? (HASH) +- Ties: two consumers of one place expiring together produce without consuming (`nextCount` in `lower/clocks.ts`); Zeroth's `test_birth_death_over_a_long_run` covers only a producer and a consumer. Under exponential clocks equal remainders have chance zero: `_breakpoint` in `simulate.py` pins only the expiring clock. Intended? (HASH, Zeroth) +- Should a coin step allow ties? Two transitions can both fire in one step, and both apply, where clocks fire one at a time. Where one firing disables another, as through an inhibitor arc, the two differ. (HASH, Zeroth) +- Should read and inhibitor arcs count as conflicts? Today they do, so two readers of one place get picks that hold them without competing. (HASH) +- How picks are driven under SPN. (Zeroth) + +### (f) Kernels and outputs + +**IR.** `outputs.: null` or `{ weight? }`. `kernel` is code, required when an output place is coloured. + +| Output | Coins, monolithic | Coins, modular | Clocks | +| --- | --- | --- | --- | +| Plain token, weight w | `P = ite(fire_T, P + w, P)` at the end of the step | `P = ite(X(fire_T), P + w, P)` in the place module | `P + 1` per event | +| Coloured token | `out_T_P_i_attr` locals, landed by compaction | refused, as in (a) | refused (`clocks-kernel`) | +| Draw in a kernel or rate | `Distribution.Gaussian(mean, c)` as `mean + c * X(z_T_k)`; `Uniform(a, b)` as `a + (b - a) * X(v_T_k)`; others refused (`distribution-unsupported`) | as monolithic | refused | + +A kernel returns a record keyed by output place, each value a list of records, bound tokens or an input place passed through. Anything else is refused with a `kernel-*` code (`kernel-missing`, `kernel-output-shape`, `kernel-output-missing`, `kernel-output-count`, `kernel-attribute-missing`) or `sort-mismatch`. + +Example (golden `drones`, `lower/monolithic/colour.test.ts`, kernel `const d = input.Hangar[0]; return { Airborne: [{ battery: d.battery, state: "flying" }] };`): + +```python + out_Launch_Airborne_0_battery = ite(sel_Launch_0, Hangar_0_battery, ite(sel_Launch_1, Hangar_1_battery, Hangar_2_battery)) + out_Launch_Airborne_0_state = 1.0 +``` + +```python + Airborne_0_battery = ite(fire_Launch & (landed_Airborne == 0.0), out_Launch_Airborne_0_battery, Airborne_0_battery) +``` + +A Gaussian draw in a rate, `Distribution.Gaussian(input.Hangar[0].battery, 4).map((v) => v + 1)`, lowers to `Hangar_0_battery + 4 * z_0 + 1` (`code/linear-code.test.ts`). + +Open questions: + +- Kernels under SPN, which has no sort for attributes. (Zeroth) + +### (g) Continuous dynamics and time + +**IR.** `dynamics.: { colour, code }` with `return tokens.map((t) => ({ attr: derivative }));`, and `places.

.dynamics: Name`. + +| Option | Produces | Status | Chosen by | +| --- | --- | --- | --- | +| Euler in `next` | one step per position before the sweep, `P_i_x = ite(P_i_present, P_i_x + dt * f, P_i_x)`, real attributes only | shipped; from `Tank_0_level = 0` the emitted step yields `1.25, 2.34375, 3.30078125` | coins, `dt` | +| LRA `delay` block | a flow `k * d(t)` | rejected: a Real cannot write a tangent, `x * A` raises `NonLinearError`, `eval.py` runs no delay block | not offered | +| Clocks | `t = Var(Clock())` external and driven by nothing; each clock flows `-1 * d(t)` against it; no `dt` | shipped; `rates: clock` does not apply to a net with dynamics (`option-not-applicable`) | `rates: clock` | + +Under coins `dt` is the round: the step length, which rates are tested over and dynamics stepped by. Under clocks a step flows until the earliest flow condition fails, then takes one round. `first(gap)` adds a round every `gap` (`test_first_takes_a_round_every_gap_and_at_the_expiry`). `simulate.py` calls `rng.expovariate` once per `Exp` term per round, fired or not, and `uniform(gap)` shares the stream. HASH's own simulator, which is not in this repository, draws from one stream, for enabled transitions only. + +The Euler step (golden `boiler`, `lower/monolithic/colour.test.ts`, `dt: 0.25`) and a clock's flow (`birthDeathPython`): + +```python + Tank_0_level = ite(Tank_0_present, Tank_0_level + 0.125 * (10.0 - Tank_0_level), Tank_0_level) # Tank: one Euler step of dt = 0.25 +``` + +```python + def flow(self, clk_Birth, ev_Birth, t): + return ite(clk_Birth >= 0, -1 * d(t), None), 0 +``` + +Open questions: + +- Will Zeroth's `Drift` generator land, so an LRA flow can read its own state? (Zeroth) +- Seed and `dt` equivalence between HASH's own simulator and `simulate.py`: one stream per transition, or a lazy driver on HASH's side? (HASH, Zeroth) + +### (h) Names, provenance and diagnostics + +**IR.** Keys are UpperCamelCase (`PlaceA`, `P3rd`). The parser refuses a reserved name in the document, and a net name whose module class would be one (`reserved-name`). `RESERVED_MODULE_NAMES` in `ir/reserved-names.ts` lists the sugar's names, the theories and sorts, `t`, `d`, `exp`, `fired`, `self` and the capitalised keywords `None`, `True`, `False`; only capitalised entries can collide, the rest are listed for the reader. Every coined identifier carries an underscore or is lowercase (`fire_T`, `clk_T`, `t`), so it cannot collide with an IR name. `lower/names.ts` holds each prefix beside its reading, and `describeName` reads a coined name back by looking its prefix up; a coin function cannot use a prefix without a reading. + +**Provenance.** `traceIr` ranges over the YAML, and the emitter writes each line of Python with its own range, so the Python trace cannot disagree with the text; each range has a what, a why and its source, and the texts follow the strategy. A module carries the net item it stands for (`source`), so no class name is read back. The simulator snapshots `module.ctrl`, hidden clocks included (`_snapshot` in `simulate.py`; `test_birth_death_over_a_long_run` runs the system composed with `hide={bclk, dclk}`); `intf` and `prvt` separate them for a reader. + +**Diagnostics.** Two channels, `errors` and `warnings`, each `{ code, message, item }`; [README.md](README.md#diagnostics) lists every code. Each lowering's `refusals` reject what it cannot express before the step is planned, so a net they reject gets no refusals from code: the modular shape on a coloured net with code reports `modular-coloured-not-lowered` alone. A refusal raised while the step is planned, such as `marking-exceeds-slots`, can come with `code-not-parsed`. + +Example, one document with five faults: `unknown-place@Go` (an output to an undeclared `Dangling`), `reserved-name@Event` (a place named `Event`), `marking-over-capacity@A` (2 tokens, capacity 1), `marking-invalid@B` (a count of 1.5) and `kind-mismatch` on the net (`kind: stochastic` with a transition that has no rate). + +### (i) Verification and execution + +**IR.** Nothing new; this level runs the emitted modules and checks them against one step of the net (`testing/reference-step.ts`). + +| Tool | Runs | Covers | Status | +| --- | --- | --- | --- | +| TypeScript interpreter (`testing/run-linear-graph.ts`) | a linear graph, one `next` per module per round in Kahn order; an undriven pick is true | the differential test: 150 random nets, plain and stochastic, 6 targets, 25 steps against `referenceStep` | shipped; refuses an SPN graph | +| `zrth.eval` | `init` and `update` blocks in LIA, LRA and BV | no delay block, no SPN, no HAVOC | Zeroth | +| `pnpm check:zrth` (`scripts/check-zrth.ts`) | every example's Python imported into a zrth checkout, under its opening options and each option changed alone | every module and the composed system construct | shipped; outside the gates, since CI has no zrth | +| `zrth.simulate` | hybrid SPN modules whose only external is `t` | the compiler's clocks output under `conflicts: sweep`, which constructs since the event flow is `0` | Zeroth; no run of the compiler's output is kept in the repository | +| `zrth.smt.z3` | LIA and LRA terms as Z3 expressions; HAVOC as fresh constants | no SPN, no BV | Zeroth | + +```ts +const TARGETS: CompilerOptions[] = [ + {}, + { shape: "modular" }, + { marking: "int" }, + { shape: "modular", marking: "int" }, + { control: "open" }, + { shape: "modular", control: "open", marking: "int", dt: 0.25 }, +]; +``` + +The simulator's call is `simulate(module, t=t, horizon=100.0, seed=1)`. + +Open questions: + +- What Z3 proves per strategy (Int places under `marking: int`, picks under nondet, nothing over SPN), and whether zrth gets an obligation library. (HASH, Zeroth) +- An executor for LIA and LRA modules with picks; `zrth.eval` takes them as state before `execute_update`. (HASH) +- float32 in `zrth.eval` against f64 in HASH's own simulator: feed the Bool outcome instead of the draw? (HASH) +- A run of the compiler's clocks output under `simulate.py`. (HASH) diff --git a/pocs/zeroth-playground/compiler/options.test.ts b/pocs/zeroth-playground/compiler/options.test.ts new file mode 100644 index 00000000..6dc5b73e --- /dev/null +++ b/pocs/zeroth-playground/compiler/options.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from "vitest"; + +import { + OPTIONS, + droppedOptions, + netFacts, + optionStates, + optionsForNet, + resolveOptions, +} from "./options"; + +import type { PetriNetIr } from "./ir/schema"; +import type { CompilerOptions } from "./options"; + +/** Two plain transitions in a row, a weighted arc and a capped place. */ +const weightedCycle: PetriNetIr = { + name: "cycle", + kind: "plain", + places: { A: null, B: null, C: { capacity: 3 } }, + marking: { A: 1 }, + transitions: { + Go: { inputs: { A: null }, outputs: { B: { weight: 2 } } }, + Back: { inputs: { B: null }, outputs: { C: null } }, + }, +}; + +/** One stochastic transition. */ +const stochastic: PetriNetIr = { + name: "arrivals", + kind: "stochastic", + places: { Arrived: null }, + transitions: { Arrive: { outputs: { Arrived: null }, rate: 2 } }, +}; + +/** One plain transition the environment controls. */ +const controllable: PetriNetIr = { + name: "switch", + kind: "plain", + places: { A: null }, + transitions: { Go: { inputs: { A: null }, controllable: true } }, +}; + +/** Two stochastic transitions that share the input place Pool. */ +const fork: PetriNetIr = { + name: "fork", + kind: "stochastic", + places: { Pool: null }, + marking: { Pool: 3 }, + transitions: { + Left: { inputs: { Pool: null }, rate: 1 }, + Right: { inputs: { Pool: null }, rate: 2 }, + }, +}; + +/** A stochastic net whose one place holds coloured tokens. */ +const coloured: PetriNetIr = { + name: "pool", + kind: "stochastic", + colours: { Ball: { size: "real" } }, + places: { Pool: { colour: "Ball" } }, + transitions: { Take: { inputs: { Pool: null }, rate: 1 } }, +}; + +/** The options that do not apply to the net, by name. */ +function notApplicable(ir: PetriNetIr, options?: CompilerOptions): string[] { + return optionStates(ir, options) + .filter((state) => state.notApplicable !== undefined) + .map((state) => state.name); +} + +describe("resolveOptions", () => { + it("gives every option a value, the default where none is given", () => { + // GIVEN no options, and the modular shape alone + // WHEN they are resolved + const defaults = resolveOptions(); + const modular = resolveOptions({ shape: "modular" }); + // THEN every option has a value, the first of its values or its number + expect(defaults).toEqual({ + shape: "monolithic", + rates: "coin", + conflicts: "sweep", + marking: "real", + control: "closed", + dt: 1, + slots: 8, + layout: "single", + }); + expect(modular).toEqual({ ...defaults, shape: "modular" }); + }); + + it("takes each choice's first value as its default", () => { + // GIVEN the options table + // WHEN each choice's default is read + const choices = Object.values(OPTIONS).filter((spec) => spec.values !== null); + // THEN it is the first value listed + for (const spec of choices) { + expect(spec.values?.[0]).toBe(spec.default); + } + }); +}); + +describe("netFacts", () => { + it("reads whether the net is stochastic off its rates, not off its kind", () => { + // GIVEN a net that says it is mixed but has no rate + const unrated: PetriNetIr = { ...weightedCycle, kind: "mixed" }; + // WHEN its facts are read + const facts = netFacts(unrated); + // THEN it is not stochastic, so the rate options do not apply + expect(facts.stochastic).toBe(false); + expect(notApplicable(unrated)).toContain("rates"); + }); + + it("puts clock rates in force only on a stochastic net without colours or dynamics", () => { + // GIVEN clock rates asked for on a stochastic net, a plain net and a coloured net + const clock: CompilerOptions = { rates: "clock" }; + // WHEN the facts are read + // THEN only the stochastic net runs on clocks + expect(netFacts(stochastic, clock).clocks).toBe(true); + expect(netFacts(weightedCycle, clock).clocks).toBe(false); + expect(netFacts(coloured, clock).clocks).toBe(false); + }); +}); + +describe("optionStates", () => { + it("greys out what does not apply to a plain net without conflicts", () => { + // GIVEN the weighted cycle, under the modular shape and under the defaults + // WHEN its options are read + // THEN the rate, choice and colour options do not apply, and the layout only under modular + expect(notApplicable(weightedCycle, { shape: "modular" })).toEqual([ + "rates", + "conflicts", + "marking", + "control", + "dt", + "slots", + ]); + expect(notApplicable(weightedCycle)).toContain("layout"); + }); + + it("keeps rates, conflicts and layout alone under clock rates", () => { + // GIVEN the fork under clock rates with its conflicts left open + const options: CompilerOptions = { rates: "clock", conflicts: "nondet" }; + // WHEN its options are read + const states = optionStates(fork, options); + // THEN the step options do not apply, and conflicts holds its value + expect(notApplicable(fork, options)).toEqual(["shape", "marking", "control", "dt", "slots"]); + expect(states.find((state) => state.name === "conflicts")?.value).toBe("nondet"); + expect(states.find((state) => state.name === "shape")?.notApplicable).toBe( + "Clock rates compose the modules in continuous time; there is no step.", + ); + }); + + it("gives a coloured net slots and no rates or marking", () => { + // GIVEN a coloured stochastic net + // WHEN its options are read + // THEN slots apply, and the rates and the marking do not + expect(notApplicable(coloured)).toEqual(["rates", "conflicts", "marking", "control", "layout"]); + }); + + it("reads the value in force, and lists the values to pick from", () => { + // GIVEN the stochastic net, modular with a step of 0.5 + // WHEN its options are read + const states = optionStates(stochastic, { shape: "modular", dt: 0.5 }); + // THEN each value is the one given or the default, and a number has no list + expect(Object.fromEntries(states.map((state) => [state.name, state.value]))).toEqual({ + shape: "modular", + rates: "coin", + conflicts: "sweep", + marking: "real", + control: "closed", + dt: "0.5", + slots: "8", + layout: "single", + }); + expect(states.find((state) => state.name === "shape")?.values).toEqual([ + "monolithic", + "modular", + ]); + expect(states.find((state) => state.name === "dt")?.values).toBeNull(); + }); +}); + +describe("optionsForNet", () => { + it("keeps only the options off their default", () => { + // GIVEN a stochastic net + // WHEN no options, options at their defaults and options off their defaults are asked for + const unset = optionsForNet(undefined, stochastic); + const defaults = optionsForNet( + { shape: "monolithic", marking: "real", control: "closed", dt: 1 }, + stochastic, + ); + const changed = optionsForNet({ shape: "modular", marking: "int", dt: 0.5 }, stochastic); + // THEN only the options off their default are kept + expect(unset).toEqual({}); + expect(defaults).toEqual({}); + expect(changed).toEqual({ shape: "modular", marking: "int", dt: 0.5 }); + }); + + it("drops the options that do not apply to the net", () => { + // GIVEN a plain net, and a plain net with a controllable transition + // WHEN options for stochastic and controllable nets are asked for + const plain = optionsForNet({ marking: "int", dt: 0.5, control: "open" }, weightedCycle); + const open = optionsForNet({ marking: "int", control: "open" }, controllable); + // THEN each net keeps only the options that bear on it + expect(plain).toEqual({}); + expect(open).toEqual({ control: "open" }); + }); + + it("keeps the rates for a stochastic net without colours, and drops the step options under clocks", () => { + // GIVEN every option set, and a stochastic net, the plain cycle and a coloured net + const busy: CompilerOptions = { + rates: "clock", + shape: "modular", + marking: "int", + control: "open", + dt: 0.5, + layout: "per-module", + }; + const rated: PetriNetIr = { + ...stochastic, + transitions: { Arrive: { outputs: { Arrived: null }, rate: 2, controllable: true } }, + }; + // WHEN the options are kept for each net + const clocked = optionsForNet(busy, rated); + const plain = optionsForNet(busy, weightedCycle); + const colouredClocks = optionsForNet({ rates: "clock" }, coloured); + const coins = optionsForNet({ rates: "coin" }, rated); + // THEN clocks drop the step options, and only a stochastic net without colours keeps the rates + expect(clocked).toEqual({ rates: "clock", layout: "per-module" }); + expect(plain).toEqual({ shape: "modular", layout: "per-module" }); + expect(colouredClocks).toEqual({}); + expect(coins).toEqual({}); + }); + + it("keeps conflicts for a net where two transitions share an input place, under either rates", () => { + // GIVEN the fork, a single stochastic transition and the plain cycle + // WHEN conflicts are asked to stay open on each + const coins = optionsForNet({ conflicts: "nondet" }, fork); + const clocks = optionsForNet({ rates: "clock", conflicts: "nondet" }, fork); + const sweep = optionsForNet({ conflicts: "sweep" }, fork); + const single = optionsForNet({ conflicts: "nondet" }, stochastic); + const plain = optionsForNet({ conflicts: "nondet" }, weightedCycle); + // THEN only the fork keeps it, and only off its default + expect(coins).toEqual({ conflicts: "nondet" }); + expect(clocks).toEqual({ rates: "clock", conflicts: "nondet" }); + expect(sweep).toEqual({}); + expect(single).toEqual({}); + expect(plain).toEqual({}); + }); + + it("keeps the layout only for a composed system", () => { + // GIVEN a stochastic net + // WHEN the layout is asked for alone, then under modular at its default and per module + const alone = optionsForNet({ layout: "per-module" }, stochastic); + const single = optionsForNet({ shape: "modular", layout: "single" }, stochastic); + const perModule = optionsForNet({ shape: "modular", layout: "per-module" }, stochastic); + const clocks = optionsForNet({ rates: "clock", layout: "per-module" }, stochastic); + // THEN only the modular shape or clock rates keep a layout off its default + expect(alone).toEqual({}); + expect(single).toEqual({ shape: "modular" }); + expect(perModule).toEqual({ shape: "modular", layout: "per-module" }); + expect(clocks).toEqual({ rates: "clock", layout: "per-module" }); + }); +}); + +describe("droppedOptions", () => { + it("warns once per option off its default that does not apply, with the reason the panel shows", () => { + // GIVEN clock rates asked for on a coloured net, with control open and the default shape + const options: CompilerOptions = { rates: "clock", control: "open", shape: "monolithic" }; + // WHEN the dropped options are read + const warnings = droppedOptions(options, coloured); + // THEN each dropped option is one warning on the net, quoting its reason + const reasons = optionStates(coloured, options); + expect(warnings).toEqual( + ["rates", "control"].map((name) => ({ + code: "option-not-applicable", + message: `${name}: ${name === "rates" ? "clock" : "open"} is ignored. ${ + reasons.find((state) => state.name === name)?.notApplicable + }`, + item: { kind: "net", name: "pool" }, + })), + ); + }); + + it("drops exactly what optionsForNet leaves out", () => { + // GIVEN every option off its default, on the fork under clocks + const options: CompilerOptions = { + shape: "modular", + rates: "clock", + conflicts: "nondet", + marking: "int", + control: "open", + dt: 0.5, + slots: 4, + layout: "per-module", + }; + // WHEN the options are kept and dropped + const kept = Object.keys(optionsForNet(options, fork)); + const dropped = droppedOptions(options, fork).map((warning) => warning.message.split(":")[0]); + // THEN every option is either kept or dropped, never both + expect(kept).toEqual(["rates", "conflicts", "layout"]); + expect(dropped).toEqual(["shape", "marking", "control", "dt", "slots"]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/options.ts b/pocs/zeroth-playground/compiler/options.ts new file mode 100644 index 00000000..c1d6df66 --- /dev/null +++ b/pocs/zeroth-playground/compiler/options.ts @@ -0,0 +1,241 @@ +import { conflictingTransitions } from "./ir/accessors"; +import { netItem } from "./ir/net-item"; + +import type { Diagnostic } from "./diagnostics"; +import type { PetriNetIr } from "./ir/schema"; + +/** + * How the compiler shapes its output. Each option names a strategy, with a + * default that reproduces one step of the net in one module. The options + * are passed beside the IR, never written in it. + * + * `OPTIONS` is the one table: each option's values, default, what it + * decides, the nets it bears on, and why it does not apply to a net. The + * types, the panel's rows, what the panel stores and the + * `option-not-applicable` warnings all follow from it. + */ + +/** What the applicability rules read off a net under the requested options. */ +export type NetFacts = { + /** Some transition has a rate. */ + stochastic: boolean; + coloured: boolean; + dynamic: boolean; + controllable: boolean; + /** Two transitions share an input place. */ + conflicting: boolean; + /** Clock rates are asked for and apply: the composition is fixed and there is no step. */ + clocks: boolean; +}; + +type OptionRules = { + /** What the option decides. */ + readonly summary: string; + /** The nets it bears on, as prose for a reader. */ + readonly appliesTo: string; + /** Why it has no effect on the net, or `undefined` when it applies. */ + notApplicable(net: NetFacts, options: ResolvedOptions): string | undefined; +}; + +export type OptionSpec = OptionRules & { + /** The values to pick from, the default first; `null` for a positive number. */ + readonly values: readonly string[] | null; + readonly default: string | number; +}; + +function choice( + rules: OptionRules & { values: Values }, +): OptionRules & { readonly values: Values; readonly default: Values[0] } { + return { ...rules, default: rules.values[0] }; +} + +function positiveNumber( + rules: OptionRules & { default: number }, +): OptionRules & { readonly values: null; readonly default: number } { + return { ...rules, values: null }; +} + +const CLOCKS_HAVE_NO_STEP = "Clock rates compose the modules in continuous time; there is no step."; + +export const OPTIONS = { + shape: choice({ + values: ["monolithic", "modular"], + summary: + "Monolithic: one module holds the whole step. Modular: one module per transition drives a firing flag, one module per place awaits the flags of its transitions, and the modules are composed.", + appliesTo: "every net under coins", + notApplicable: (net) => (net.clocks ? CLOCKS_HAVE_NO_STEP : undefined), + }), + rates: choice({ + values: ["coin", "clock"], + summary: + "Coin: a rate is tested against a uniform draw each step of dt, in a linear theory. Clock: in the SPN theory, each transition owns a clock armed with exp(rate) and an event it toggles when it fires, each place is a Nat counter, and time is continuous.", + appliesTo: "a stochastic net without colours or dynamics", + notApplicable: (net) => + !net.stochastic + ? "A plain net has no rates to test." + : net.coloured || net.dynamic + ? "A coloured net, or one with dynamics, compiles under coins only." + : undefined, + }), + conflicts: choice({ + values: ["sweep", "nondet"], + summary: + "Sweep: transitions that share an input place fire in record order, and a later one reads the marking the earlier ones left. Nondet: each transition in a conflict also waits for a Bool pick that nothing drives, so a proof ranges over every way the conflict resolves.", + appliesTo: "a net where two transitions share an input place", + notApplicable: (net) => + net.conflicting ? undefined : "No two transitions share an input place.", + }), + marking: choice({ + values: ["real", "int"], + summary: + "Real: every place is a Real and each guard tests its own draw. Int: places are Int, and one LRA module per transition turns its draw into a Bool flag the guard reads.", + appliesTo: "a stochastic uncoloured net under coins", + notApplicable: (net) => + net.clocks + ? "Clock rates count in Nat." + : !net.stochastic + ? "A plain net counts in Int." + : net.coloured + ? "A coloured place's slots are Real." + : undefined, + }), + control: choice({ + values: ["closed", "open"], + summary: + "Closed: a controllable transition fires whenever it is enabled. Open: it also waits for a Bool choice from outside, so the module is open to a controller.", + appliesTo: "a net with a controllable transition, under coins", + notApplicable: (net) => + net.clocks + ? "Clock rates take no external choice." + : net.controllable + ? undefined + : "No transition is marked controllable.", + }), + dt: positiveNumber({ + default: 1, + summary: "The step length: a rate is tested over it, and dynamics take one Euler step of it.", + appliesTo: "a net with rates or dynamics, under coins", + notApplicable: (net) => + net.clocks + ? CLOCKS_HAVE_NO_STEP + : net.stochastic || net.dynamic + ? undefined + : "A plain net without dynamics has no step length to test over.", + }), + slots: positiveNumber({ + default: 8, + summary: + "The slots a coloured place without a capacity gets. A produced token that finds no free slot sets the place's overflow flag.", + appliesTo: "a coloured net", + notApplicable: (net) => (net.coloured ? undefined : "No place is coloured."), + }), + layout: choice({ + values: ["single", "per-module"], + summary: + "Single: one Python file holds the variables, the modules and the system. Per-module: each module class has a file of its own, and net.py declares the variables, imports the modules and composes them.", + appliesTo: "a net under the modular shape or clock rates", + // The annotations keep this rule's type from depending on the types derived from OPTIONS. + notApplicable: (net: NetFacts, options: ResolvedOptions): string | undefined => + options.shape === "modular" || net.clocks + ? undefined + : "One module needs one file; the modular shape or clock rates compose.", + }), +} satisfies Record; + +export type OptionName = keyof typeof OPTIONS; + +type OptionValue = Spec extends { readonly values: readonly (infer Value)[] } + ? Value + : number; + +/** The options a caller asks for; one left out takes its default. */ +export type CompilerOptions = { [Name in OptionName]?: OptionValue<(typeof OPTIONS)[Name]> }; + +export type ResolvedOptions = Required; + +/** Every option, in the order of `OPTIONS`. */ +export const OPTION_NAMES = Object.keys(OPTIONS) as OptionName[]; + +/** Every option: the value given, or the default. */ +export function resolveOptions(options: CompilerOptions = {}): ResolvedOptions { + return Object.fromEntries( + OPTION_NAMES.map((name) => [name, options[name] ?? OPTIONS[name].default]), + ) as ResolvedOptions; +} + +export function netFacts(ir: PetriNetIr, options?: CompilerOptions): NetFacts { + const places = Object.values(ir.places); + const transitions = Object.values(ir.transitions); + const resolved = resolveOptions(options); + const net: NetFacts = { + stochastic: transitions.some((transition) => transition.rate !== undefined), + coloured: places.some((place) => place?.colour !== undefined), + dynamic: places.some((place) => place?.dynamics !== undefined), + controllable: transitions.some((transition) => transition.controllable === true), + conflicting: conflictingTransitions(ir.transitions).size > 0, + clocks: false, + }; + // The rates rule says when clocks apply; it reads no fact that depends on clocks. + return { + ...net, + clocks: resolved.rates === "clock" && OPTIONS.rates.notApplicable(net, resolved) === undefined, + }; +} + +/** One option as the panel shows it for a net. */ +export type OptionState = { + name: OptionName; + /** The value in force: the one given, or the default. */ + value: string; + values: readonly string[] | null; + /** Why the option has no effect on this net; absent when it applies. */ + notApplicable?: string; +}; + +export function optionStates(ir: PetriNetIr, options?: CompilerOptions): OptionState[] { + const net = netFacts(ir, options); + const resolved = resolveOptions(options); + return OPTION_NAMES.map((name) => { + const reason = OPTIONS[name].notApplicable(net, resolved); + return { + name, + value: String(resolved[name]), + values: OPTIONS[name].values, + ...(reason === undefined ? {} : { notApplicable: reason }), + }; + }); +} + +/** The options that bear on the net: those that apply and are off their default. */ +export function optionsForNet( + options: CompilerOptions | undefined, + ir: PetriNetIr, +): CompilerOptions { + const resolved = resolveOptions(options); + return Object.fromEntries( + optionStates(ir, options).flatMap(({ name, notApplicable }) => + notApplicable === undefined && resolved[name] !== OPTIONS[name].default + ? [[name, resolved[name]]] + : [], + ), + ); +} + +/** + * One `option-not-applicable` warning per option asked for off its default + * that does not apply to the net, with the reason the panel shows. + */ +export function droppedOptions(options: CompilerOptions | undefined, ir: PetriNetIr): Diagnostic[] { + const resolved = resolveOptions(options); + return optionStates(ir, options).flatMap(({ name, value, notApplicable }) => + notApplicable === undefined || resolved[name] === OPTIONS[name].default + ? [] + : [ + { + code: "option-not-applicable", + message: `${name}: ${value} is ignored. ${notApplicable}`, + item: netItem(ir.name), + }, + ], + ); +} diff --git a/pocs/zeroth-playground/compiler/readme.test.ts b/pocs/zeroth-playground/compiler/readme.test.ts new file mode 100644 index 00000000..23ac1a02 --- /dev/null +++ b/pocs/zeroth-playground/compiler/readme.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import README from "./README.md?raw"; +import { DIAGNOSTIC_CODES } from "./diagnostics"; +import { OPTION_NAMES, OPTIONS } from "./options"; + +/** The body rows of every table in the README whose header row is `header`, as trimmed cells. */ +function tableRows(header: string): string[][] { + return README.split("\n\n") + .map((block) => block.trim().split("\n")) + .filter(([first]) => first === header) + .flatMap((lines) => + lines.slice(2).map((line) => + line + .slice(1, -1) + .split("|") + .map((cell) => cell.trim()), + ), + ); +} + +function codeSpan(text: string | number): string { + return `\`${text}\``; +} + +describe("the README", () => { + it("lists every option in the order of OPTIONS, with its values, what it applies to and what it decides", () => { + // GIVEN the options table in the README + const rows = tableRows("| Option | Values, default first | Applies to | Decides |"); + // THEN each row says what the option's entry in OPTIONS says + expect(rows).toEqual( + OPTION_NAMES.map((name) => { + const { values, appliesTo, summary } = OPTIONS[name]; + const choices = + values === null + ? `${codeSpan(OPTIONS[name].default)}, or any positive number` + : values.map(codeSpan).join(", "); + return [codeSpan(name), choices, appliesTo, summary]; + }), + ); + }); + + it("lists every diagnostic code in the order of DIAGNOSTIC_CODES, with its meaning word for word", () => { + // GIVEN the diagnostics tables in the README + const rows = tableRows("| Code | Meaning |"); + // THEN together they hold one row per code, with the meaning DIAGNOSTIC_CODES gives it + expect(rows).toEqual( + Object.entries(DIAGNOSTIC_CODES).map(([code, meaning]) => [codeSpan(code), meaning]), + ); + }); +}); diff --git a/pocs/zeroth-playground/compiler/scripts/check-zrth.ts b/pocs/zeroth-playground/compiler/scripts/check-zrth.ts new file mode 100644 index 00000000..ad78663a --- /dev/null +++ b/pocs/zeroth-playground/compiler/scripts/check-zrth.ts @@ -0,0 +1,239 @@ +/** + * Checks that the Python the compiler writes constructs under zrth. Every + * example is compiled under the options it opens with and under each variant + * `optionVariants` lists; each compiled net is written to a temporary folder + * and imported by `construct_net.py`, in the zrth checkout ZRTH_PATH names. + * A net the compiler refuses is reported as refused and not run. + * + * ZRTH_PATH=/path/to/zrth pnpm check:zrth + * + * It prints one row per example and option set, then each raise with the + * generated lines it passed through, and exits 1 when anything raises. + */ +import { execFile } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { availableParallelism, tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { compile, parsePetriNetIr } from "../index"; +import { optionsLabel, optionVariants } from "./option-variants"; + +import type { CompiledFile, CompilerOptions } from "../index"; + +const run = promisify(execFile); + +const EXAMPLES = path.resolve(import.meta.dirname, "../../examples"); +const RUNNER = path.resolve(import.meta.dirname, "construct_net.py"); + +const USAGE = `check:zrth imports the Python the compiler writes for every example into zrth. +Set ZRTH_PATH to a checkout of zrth, Zeroth's reactive-modules library, at the commit to +check against, with its Python package built (just py-rebuild) and uv on the PATH. +compiler/README.md gives the setup. + + ZRTH_PATH=/path/to/zrth pnpm check:zrth`; + +type Frame = { file: string; line: number; code: string }; + +type Outcome = + | { kind: "constructs" } + | { kind: "raises"; error: string; frames: Frame[]; folder: string } + | { kind: "refused"; codes: string[] }; + +type Example = { id: string; order: number; opening: CompilerOptions; ir: string }; + +type Case = { example: string; options: CompilerOptions; opening: boolean }; + +type Compiled = Case & ({ files: CompiledFile[] } | { codes: string[] }); + +function fail(message: string): never { + console.error(message); + process.exit(2); +} + +async function readExample(id: string): Promise { + const folder = path.join(EXAMPLES, id); + const meta = ( + (await import(pathToFileURL(path.join(folder, "meta.ts")).href)) as { + default: { order: number; options: CompilerOptions }; + } + ).default; + return { + id, + order: meta.order, + opening: meta.options, + ir: readFileSync(path.join(folder, "net.pn.yaml"), "utf8"), + }; +} + +async function readExamples(): Promise { + const ids = readdirSync(EXAMPLES, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && existsSync(path.join(EXAMPLES, entry.name, "net.pn.yaml")), + ) + .map((entry) => entry.name); + const examples = await Promise.all(ids.map(readExample)); + return examples.toSorted((a, b) => a.order - b.order); +} + +function casesOf(example: Example): Case[] { + const parsed = parsePetriNetIr(example.ir); + if (!parsed.ok) { + fail( + `examples/${example.id}/net.pn.yaml is not an IR: ${parsed.errors.map((error) => error.message).join("; ")}`, + ); + } + return optionVariants(parsed.ir, example.opening).map((options, index) => ({ + example: example.id, + options, + opening: index === 0, + })); +} + +function compiled(example: Example, entry: Case): Compiled { + const { files, errors } = compile(example.ir, { options: entry.options }); + return errors.length > 0 + ? { ...entry, codes: [...new Set(errors.map((error) => error.code))] } + : { ...entry, files }; +} + +function writeFiles(folder: string, files: readonly CompiledFile[]): void { + mkdirSync(folder, { recursive: true }); + for (const file of files) { + writeFileSync(path.join(folder, file.path), file.text); + } +} + +/** The last line of a process's output: where Python prints the exception that ended it. */ +function lastLine(text: string): string { + return text.trim().split("\n").at(-1) ?? ""; +} + +async function construct(zrth: string, folder: string): Promise { + try { + const { stdout } = await run("uv", ["run", "--no-sync", "python", RUNNER, folder], { + cwd: zrth, + }); + const result = JSON.parse(lastLine(stdout)) as + | { ok: true } + | { ok: false; error: string; frames: Frame[] }; + return result.ok + ? { kind: "constructs" } + : { kind: "raises", error: result.error, frames: result.frames, folder }; + } catch (error) { + const stderr = (error as { stderr?: string }).stderr ?? String(error); + return { kind: "raises", error: lastLine(stderr), frames: [], folder }; + } +} + +/** Runs each item through `task`, at most `size` at a time, and keeps the results in order. */ +async function inPool( + items: readonly T[], + size: number, + task: (item: T, index: number) => Promise, +): Promise { + const results: R[] = []; + let next = 0; + async function worker(): Promise { + while (next < items.length) { + const index = next++; + results[index] = await task(items[index] as T, index); + } + } + await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker)); + return results; +} + +async function checkZrthRuns(zrth: string): Promise { + try { + await run("uv", ["run", "--no-sync", "python", "-c", "import zrth.sugar"], { cwd: zrth }); + } catch (error) { + const stderr = (error as { stderr?: string }).stderr; + fail( + `${USAGE}\n\nzrth does not import from ${zrth}: ${stderr === undefined ? String(error) : lastLine(stderr)}`, + ); + } +} + +function resultText(outcome: Outcome): string { + return outcome.kind === "refused" ? `refused: ${outcome.codes.join(", ")}` : outcome.kind; +} + +function table(rows: readonly (readonly string[])[]): string { + const widths = + rows[0]?.map((_, column) => Math.max(...rows.map((row) => row[column]?.length ?? 0))) ?? []; + return rows + .map((row) => + row + .map((cell, column) => cell.padEnd(widths[column] ?? 0)) + .join(" ") + .trimEnd(), + ) + .join("\n"); +} + +function report(results: readonly (Case & { outcome: Outcome })[]): string { + const rows = results.map(({ example, options, opening, outcome }) => [ + example, + `${optionsLabel(options)}${opening ? " (opens with)" : ""}`, + resultText(outcome), + ]); + const raises = results.flatMap(({ example, options, outcome }) => + outcome.kind === "raises" + ? [ + [ + `${example}, ${optionsLabel(options)}: ${outcome.error}`, + ...outcome.frames.map((frame) => ` ${frame.file}:${frame.line} ${frame.code}`), + ` files in ${outcome.folder}`, + ].join("\n"), + ] + : [], + ); + const count = (kind: Outcome["kind"]) => + results.filter(({ outcome }) => outcome.kind === kind).length; + return [ + table([["example", "options", "result"], ...rows]), + "", + `${count("constructs")} construct, ${count("raises")} raise, ${count("refused")} refused.`, + ...(raises.length === 0 ? [] : ["", "Raises:", ...raises]), + ].join("\n"); +} + +const zrth = process.env["ZRTH_PATH"]; +if (zrth === undefined || zrth === "") { + fail(`${USAGE}\n\nZRTH_PATH is not set.`); +} +if (!existsSync(zrth)) { + fail(`${USAGE}\n\nZRTH_PATH names no folder: ${zrth}`); +} +await checkZrthRuns(zrth); + +const examples = await readExamples(); +const cases = examples.flatMap((example) => + casesOf(example).map((entry) => compiled(example, entry)), +); +const scratch = mkdtempSync(path.join(tmpdir(), "check-zrth-")); +const results = await inPool(cases, availableParallelism(), async (entry, index) => { + if ("codes" in entry) { + return { ...entry, outcome: { kind: "refused", codes: entry.codes } satisfies Outcome }; + } + const folder = path.join(scratch, `${index}-${entry.example}`); + writeFiles(folder, entry.files); + return { ...entry, outcome: await construct(zrth, folder) }; +}); + +console.log(report(results)); +const raised = results.some(({ outcome }) => outcome.kind === "raises"); +if (!raised) { + rmSync(scratch, { recursive: true, force: true }); +} +process.exitCode = raised ? 1 : 0; diff --git a/pocs/zeroth-playground/compiler/scripts/construct_net.py b/pocs/zeroth-playground/compiler/scripts/construct_net.py new file mode 100644 index 00000000..55b121df --- /dev/null +++ b/pocs/zeroth-playground/compiler/scripts/construct_net.py @@ -0,0 +1,39 @@ +"""Imports the compiled net.py in the folder given and says whether the net constructs. + +Importing net.py builds every module class and the composed system, so a construction +that zrth refuses raises there. The result is one line of JSON on stdout: `ok`, and on a +raise the error with the lines of the generated files it passed through, outermost first. +Run it from a zrth checkout: `uv run --no-sync python construct_net.py `. +""" + +import importlib +import json +import os +import sys +import traceback + +from zrth import Module + + +def generated_frames(error: BaseException, folder: str) -> list[dict]: + """The frames of the traceback that sit in the compiled files, outermost first.""" + return [ + {"file": os.path.relpath(frame.filename, folder), "line": frame.lineno, "code": frame.line} + for frame in traceback.extract_tb(error.__traceback__) + if os.path.abspath(frame.filename).startswith(folder + os.sep) + ] + + +def construct(folder: str) -> dict: + sys.path.insert(0, folder) + try: + net = importlib.import_module("net").net + if not isinstance(net, Module): + return {"ok": False, "error": f"net is a {type(net).__name__}, not a zrth Module", "frames": []} + return {"ok": True} + except Exception as error: # every raise is a result to report, whatever its type + return {"ok": False, "error": f"{type(error).__name__}: {error}", "frames": generated_frames(error, folder)} + + +if __name__ == "__main__": + print(json.dumps(construct(os.path.abspath(sys.argv[1])))) diff --git a/pocs/zeroth-playground/compiler/scripts/option-variants.test.ts b/pocs/zeroth-playground/compiler/scripts/option-variants.test.ts new file mode 100644 index 00000000..a96d3074 --- /dev/null +++ b/pocs/zeroth-playground/compiler/scripts/option-variants.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { birthDeathIr } from "../testing/birth-death.fixtures"; +import { forkClockedIr, forkClockedOptions, forkIr } from "../testing/fork.fixtures"; +import { optionsLabel, optionVariants } from "./option-variants"; + +describe("the option sets the zrth check compiles a net under", () => { + it("opens with the net's options, then changes one applicable option at a time", () => { + // GIVEN a plain fork, whose conflict is the only choice beside the shape, opened modular + // WHEN its variants are listed + const variants = optionVariants(forkIr, { shape: "modular" }); + // THEN the opening comes first, and no option that needs rates appears + expect(variants).toEqual([ + { shape: "modular" }, + {}, + { shape: "modular", conflicts: "nondet" }, + { shape: "modular", layout: "per-module" }, + ]); + }); + + it("drops the options clock rates turn off, and lists the clocked conflict once", () => { + // GIVEN the fork at rates, opened under clocks with its conflict left open + // WHEN its variants are listed + const variants = optionVariants(forkClockedIr, forkClockedOptions); + // THEN the shape is never swept under clocks, and the combined change adds no second copy of the opening + expect(variants).toEqual([ + { rates: "clock", conflicts: "nondet" }, + { conflicts: "nondet" }, + { rates: "clock" }, + { rates: "clock", conflicts: "nondet", layout: "per-module" }, + ]); + }); + + it("tries clock rates and the Int marking on a stochastic net opened under coins", () => { + // GIVEN birth-death, opened modular under coins + // WHEN its variants are labelled + const labels = optionVariants(birthDeathIr, { shape: "modular" }).map(optionsLabel); + // THEN clocks drop the shape, and the defaults read as such + expect(labels).toEqual([ + "shape=modular", + "rates=clock", + "defaults", + "shape=modular, marking=int", + "shape=modular, layout=per-module", + ]); + }); +}); diff --git a/pocs/zeroth-playground/compiler/scripts/option-variants.ts b/pocs/zeroth-playground/compiler/scripts/option-variants.ts new file mode 100644 index 00000000..6237903f --- /dev/null +++ b/pocs/zeroth-playground/compiler/scripts/option-variants.ts @@ -0,0 +1,64 @@ +import { OPTIONS, optionStates, optionsForNet } from "../index"; + +import type { CompilerOptions, OptionName, PetriNetIr } from "../index"; + +/** + * The option sets the zrth check compiles a net under: the ones it opens + * with, then each value of a swept option changed alone, then the + * combinations below. A set is kept only when every option it changes + * applies to the net, and it is written as the options in force, so two + * sets that compile alike appear once. + */ + +/** The options whose values change the Python's shape, each tried alone. */ +export const SWEPT_OPTIONS: readonly OptionName[] = [ + "rates", + "shape", + "conflicts", + "marking", + "control", + "layout", +]; + +/** Changes tried together, because one value only shows its output beside the other. */ +export const COMBINED_CHANGES: readonly CompilerOptions[] = [ + { rates: "clock", conflicts: "nondet" }, +]; + +function appliesAll(ir: PetriNetIr, options: CompilerOptions, changed: readonly string[]): boolean { + return optionStates(ir, options).every( + ({ name, notApplicable }) => !changed.includes(name) || notApplicable === undefined, + ); +} + +function sweptChanges(): CompilerOptions[] { + return SWEPT_OPTIONS.flatMap((name) => + (OPTIONS[name].values ?? []).map((value): CompilerOptions => ({ [name]: value })), + ); +} + +/** The opening options first, then every applicable change, each as the options in force. */ +export function optionVariants(ir: PetriNetIr, opening: CompilerOptions): CompilerOptions[] { + const candidates = [ + optionsForNet(opening, ir), + ...[...sweptChanges(), ...COMBINED_CHANGES] + .map((change) => ({ change, options: { ...opening, ...change } })) + .filter(({ change, options }) => appliesAll(ir, options, Object.keys(change))) + .map(({ options }) => optionsForNet(options, ir)), + ]; + const seen = new Set(); + return candidates.filter((options) => { + const key = JSON.stringify(options); + const fresh = !seen.has(key); + seen.add(key); + return fresh; + }); +} + +/** `rates=clock, conflicts=nondet`, or `defaults` when every option is at its default. */ +export function optionsLabel(options: CompilerOptions): string { + const entries = Object.entries(options); + return entries.length === 0 + ? "defaults" + : entries.map(([name, value]) => `${name}=${value}`).join(", "); +} diff --git a/pocs/zeroth-playground/compiler/scripts/resolve-ts.ts b/pocs/zeroth-playground/compiler/scripts/resolve-ts.ts new file mode 100644 index 00000000..6d7d4900 --- /dev/null +++ b/pocs/zeroth-playground/compiler/scripts/resolve-ts.ts @@ -0,0 +1,17 @@ +/** + * Lets plain Node run the compiler's TypeScript: a relative import written + * without an extension, as the compiler writes them for Vite, resolves to its + * `.ts` file. Preload it with `node --import ./compiler/scripts/resolve-ts.ts`. + */ +import { registerHooks } from "node:module"; + +const RELATIVE = /^\.\.?\//u; +const HAS_EXTENSION = /\.[cm]?[jt]sx?$/u; + +registerHooks({ + resolve(specifier, context, nextResolve) { + return RELATIVE.test(specifier) && !HAS_EXTENSION.test(specifier) + ? nextResolve(`${specifier}.ts`, context) + : nextResolve(specifier, context); + }, +}); diff --git a/pocs/zeroth-playground/compiler/testing/birth-death.fixtures.ts b/pocs/zeroth-playground/compiler/testing/birth-death.fixtures.ts new file mode 100644 index 00000000..3cc624a0 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/birth-death.fixtures.ts @@ -0,0 +1,86 @@ +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** The birth-death process of Zeroth's `birth_death.py`, as an IR. */ +export const birthDeathIr: PetriNetIr = { + name: "birth_death", + kind: "stochastic", + places: { Population: null }, + transitions: { + Birth: { outputs: { Population: null }, rate: 2 }, + Death: { inputs: { Population: null }, rate: 1 }, + }, +}; + +/** The options it is compiled under: clock rates. */ +export const birthDeathOptions: CompilerOptions = { rates: "clock" }; + +/** + * What the birth-death IR compiles to: the same atoms as `birth_death.py` + * under zrth, up to variable and class names. + */ +export const birthDeathPython = `from zrth import SPN, Clock, Event, Nat, Var +from zrth import Module as compose +from zrth.sugar import Module, d, ite, fired, exp + +t = Var(Clock()) # the time reference + +Population = Var(Nat()) + +clk_Birth = Var(Clock()) # time left until Birth fires +clk_Death = Var(Clock()) # time left until Death fires + +ev_Birth = Var(Event()) # toggles when Birth fires +ev_Death = Var(Event()) # toggles when Death fires + + +class Transition_Birth(Module): + """Birth: nothing -> Population, at rate 2""" + + def init(self, t): + return exp(2.0), False + + def next(self, clk_Birth, ev_Birth, t): + fires_Birth = clk_Birth == 0 + return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) + + def flow(self, clk_Birth, ev_Birth, t): + return ite(clk_Birth >= 0, -1 * d(t), None), 0 + + +class Transition_Death(Module): + """Death: Population -> nothing, at rate 1""" + + def init(self, Population, t): + return exp(1.0), False + + def next(self, clk_Death, ev_Death, Population, t): + fires_Death = (clk_Death == 0) & (Population != 0) + return ite(fires_Death, exp(1.0), clk_Death), ite(fires_Death, ~ev_Death, None) + + def flow(self, clk_Death, ev_Death, Population, t): + return ite(clk_Death >= 0, ite(Population != 0, -1 * d(t), 0 * d(t)), None), 0 + + +class Place_Population(Module): + """Population: added by Birth, taken by Death""" + + def init(self, ev_Birth, ev_Death): + return 0 + + def next(self, Population, ev_Birth, ev_Death): + fired_Birth = fired(ev_Birth) + fired_Death = fired(ev_Death) + return ite(fired_Birth & ~fired_Death, Population + 1, ite(fired_Death & ~fired_Birth & (Population != 0), Population - 1, Population)) + + +transition_Birth = Transition_Birth(theory=SPN, ctrl=(clk_Birth, ev_Birth), extl=(t,)) +transition_Death = Transition_Death(theory=SPN, ctrl=(clk_Death, ev_Death), extl=(Population, t)) +place_Population = Place_Population(theory=SPN, ctrl=(Population,), extl=(ev_Birth, ev_Death)) +net = compose( + transition_Birth, + transition_Death, + place_Population, + hide={clk_Birth, clk_Death}, +) +`; diff --git a/pocs/zeroth-playground/compiler/testing/code-builders.ts b/pocs/zeroth-playground/compiler/testing/code-builders.ts new file mode 100644 index 00000000..00528b98 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/code-builders.ts @@ -0,0 +1,54 @@ +import type { CodeBinaryOp, CodeExpr, CodeFunction } from "../code/code-tree"; + +/** Small builders for code trees a test writes by hand, without a captured parse. */ + +export function lit(value: number): CodeExpr { + return { kind: "numberLit", value }; +} + +export function text(value: string): CodeExpr { + return { kind: "stringLit", value }; +} + +export function local(name: string): CodeExpr { + return { kind: "localRef", name }; +} + +/** `input.[index]`: a bound token. */ +export function token(place: string, index: number): CodeExpr { + return { + kind: "indexAccess", + target: { kind: "fieldAccess", target: local("input"), field: place }, + index: lit(index), + }; +} + +export function record(entries: Record): CodeExpr { + return { + kind: "recordLit", + entries: Object.entries(entries).map(([key, value]) => ({ key, value })), + }; +} + +export function array(...elements: CodeExpr[]): CodeExpr { + return { kind: "arrayLit", elements }; +} + +/** `input.[index].`: one attribute of a bound token. */ +export function tokenAttribute(place: string, index: number, attribute: string): CodeExpr { + return { kind: "fieldAccess", target: token(place, index), field: attribute }; +} + +export function op(left: CodeExpr, operator: CodeBinaryOp, right: CodeExpr): CodeExpr { + return { kind: "binary", op: operator, left, right }; +} + +/** A guard, rate or kernel over `input`. */ +export function lambdaOf(body: CodeExpr): CodeFunction { + return { params: [{ name: "input" }], body }; +} + +/** Dynamics over `tokens`. */ +export function dynamicsOf(body: CodeExpr): CodeFunction { + return { params: [{ name: "tokens" }], body }; +} diff --git a/pocs/zeroth-playground/compiler/testing/code-trees.json b/pocs/zeroth-playground/compiler/testing/code-trees.json new file mode 100644 index 00000000..b31d1bef --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/code-trees.json @@ -0,0 +1,1056 @@ +{ + "lambda": { + "const drone = input.Hangar[0];\nconst low = drone.battery < 20;\nreturn low || (!low && input.Hangar.length >= 1);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "let", + "bindings": [ + { + "name": "drone", + "value": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + } + }, + { + "name": "low", + "value": { + "kind": "binary", + "op": "<", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "drone" + }, + "field": "battery" + }, + "right": { + "kind": "numberLit", + "value": 20 + } + } + } + ], + "body": { + "kind": "binary", + "op": "||", + "left": { + "kind": "localRef", + "name": "low" + }, + "right": { + "kind": "binary", + "op": "&&", + "left": { + "kind": "unary", + "op": "!", + "operand": { + "kind": "localRef", + "name": "low" + } + }, + "right": { + "kind": "binary", + "op": ">=", + "left": { + "kind": "length", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + } + }, + "right": { + "kind": "numberLit", + "value": 1 + } + } + } + } + } + }, + "return -input.Hangar[0].battery + 2;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "+", + "left": { + "kind": "unary", + "op": "-", + "operand": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + } + }, + "right": { + "kind": "numberLit", + "value": 2 + } + } + }, + "return 0.5 * (input.Hangar[0].battery / 4);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "*", + "left": { + "kind": "numberLit", + "value": 0.5 + }, + "right": { + "kind": "binary", + "op": "/", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + }, + "right": { + "kind": "numberLit", + "value": 4 + } + } + } + }, + "return 0.6 * (input.Hangar[0].battery / 100);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "*", + "left": { + "kind": "numberLit", + "value": 0.6 + }, + "right": { + "kind": "binary", + "op": "/", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + }, + "right": { + "kind": "numberLit", + "value": 100 + } + } + } + }, + "return 0.6 * (input.Pool[0].x / 100);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "*", + "left": { + "kind": "numberLit", + "value": 0.6 + }, + "right": { + "kind": "binary", + "op": "/", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Pool" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "x" + }, + "right": { + "kind": "numberLit", + "value": 100 + } + } + } + }, + "return Distribution.Gaussian(input.Hangar[0].battery, 4).map((v) => v + 1);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "distributionMap", + "base": { + "kind": "distribution", + "dist": "gaussian", + "args": [ + { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + }, + { + "kind": "numberLit", + "value": 4 + } + ] + }, + "param": { + "name": "v" + }, + "body": { + "kind": "binary", + "op": "+", + "left": { + "kind": "localRef", + "name": "v" + }, + "right": { + "kind": "numberLit", + "value": 1 + } + } + } + }, + "return Math.abs(input.Hangar[0].battery);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "mathCall", + "fn": "abs", + "args": [ + { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + } + ] + } + }, + "return Math.max(input.Hangar[0].battery, 50);": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "mathCall", + "fn": "max", + "args": [ + { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + }, + { + "kind": "numberLit", + "value": 50 + } + ] + } + }, + "return input.Airborne[0].battery < 20 || input.Airborne[0].state !== \"flying\";": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "||", + "left": { + "kind": "binary", + "op": "<", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Airborne" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + }, + "right": { + "kind": "numberLit", + "value": 20 + } + }, + "right": { + "kind": "binary", + "op": "!=", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Airborne" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "state" + }, + "right": { + "kind": "stringLit", + "value": "flying" + } + } + } + }, + "return input.Hangar[0].battery > 50 ? 2 : 0.5;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "cond", + "condition": { + "kind": "binary", + "op": ">", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + }, + "right": { + "kind": "numberLit", + "value": 50 + } + }, + "thenBranch": { + "kind": "numberLit", + "value": 2 + }, + "elseBranch": { + "kind": "numberLit", + "value": 0.5 + } + } + }, + "return input.Hangar[0].battery;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "battery" + } + }, + "return input.Hangar[0].state !== \"lost\";": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "!=", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "state" + }, + "right": { + "kind": "stringLit", + "value": "lost" + } + } + }, + "return input.Hangar[0].state === \"flying\";": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": "==", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "state" + }, + "right": { + "kind": "stringLit", + "value": "flying" + } + } + }, + "return input.Population.length > 2;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": ">", + "left": { + "kind": "length", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Population" + } + }, + "right": { + "kind": "numberLit", + "value": 2 + } + } + }, + "return input.Population.length;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "length", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Population" + } + } + }, + "return input.Tank[0].level >= 8;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": ">=", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Tank" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "level" + }, + "right": { + "kind": "numberLit", + "value": 8 + } + } + }, + "return input.Tank[0].level >= 8;\n": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "binary", + "op": ">=", + "left": { + "kind": "fieldAccess", + "target": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Tank" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + }, + "field": "level" + }, + "right": { + "kind": "numberLit", + "value": 8 + } + } + }, + "return true;": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "boolLit", + "value": true + } + } + }, + "kernel": { + "const d = input.Hangar[0];\nreturn { Airborne: [{ battery: d.battery, state: \"flying\" }] };": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "let", + "bindings": [ + { + "name": "d", + "value": { + "kind": "indexAccess", + "target": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "input" + }, + "field": "Hangar" + }, + "index": { + "kind": "numberLit", + "value": 0 + } + } + } + ], + "body": { + "kind": "recordLit", + "entries": [ + { + "key": "Airborne", + "value": { + "kind": "arrayLit", + "elements": [ + { + "kind": "recordLit", + "entries": [ + { + "key": "battery", + "value": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "d" + }, + "field": "battery" + } + }, + { + "key": "state", + "value": { + "kind": "stringLit", + "value": "flying" + } + } + ] + } + ] + } + } + ] + } + } + }, + "return { Hangar: [{ battery: 100, state: \"idle\" }] };": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "recordLit", + "entries": [ + { + "key": "Hangar", + "value": { + "kind": "arrayLit", + "elements": [ + { + "kind": "recordLit", + "entries": [ + { + "key": "battery", + "value": { + "kind": "numberLit", + "value": 100 + } + }, + { + "key": "state", + "value": { + "kind": "stringLit", + "value": "idle" + } + } + ] + } + ] + } + } + ] + } + }, + "return {};": { + "params": [ + { + "name": "input" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "recordLit", + "entries": [] + } + } + }, + "dynamics": { + "return tokens.map(() => ({ battery: -1 }));": { + "params": [ + { + "name": "tokens" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "arrayMap", + "target": { + "kind": "localRef", + "name": "tokens" + }, + "param": { + "name": "__element" + }, + "body": { + "kind": "recordLit", + "entries": [ + { + "key": "battery", + "value": { + "kind": "numberLit", + "value": -1 + } + } + ] + } + } + }, + "return tokens.map((d) => ({ battery: -2.5 }));": { + "params": [ + { + "name": "tokens" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "arrayMap", + "target": { + "kind": "localRef", + "name": "tokens" + }, + "param": { + "name": "d" + }, + "body": { + "kind": "recordLit", + "entries": [ + { + "key": "battery", + "value": { + "kind": "numberLit", + "value": -2.5 + } + } + ] + } + } + }, + "return tokens.map((t) => ({ level: 0.5 * (10 - t.level) }));": { + "params": [ + { + "name": "tokens" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "arrayMap", + "target": { + "kind": "localRef", + "name": "tokens" + }, + "param": { + "name": "t" + }, + "body": { + "kind": "recordLit", + "entries": [ + { + "key": "level", + "value": { + "kind": "binary", + "op": "*", + "left": { + "kind": "numberLit", + "value": 0.5 + }, + "right": { + "kind": "binary", + "op": "-", + "left": { + "kind": "numberLit", + "value": 10 + }, + "right": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "t" + }, + "field": "level" + } + } + } + } + ] + } + } + }, + "return tokens.map((t) => ({ level: 0.5 * (10 - t.level) }));\n": { + "params": [ + { + "name": "tokens" + }, + { + "name": "parameters" + } + ], + "body": { + "kind": "arrayMap", + "target": { + "kind": "localRef", + "name": "tokens" + }, + "param": { + "name": "t" + }, + "body": { + "kind": "recordLit", + "entries": [ + { + "key": "level", + "value": { + "kind": "binary", + "op": "*", + "left": { + "kind": "numberLit", + "value": 0.5 + }, + "right": { + "kind": "binary", + "op": "-", + "left": { + "kind": "numberLit", + "value": 10 + }, + "right": { + "kind": "fieldAccess", + "target": { + "kind": "localRef", + "name": "t" + }, + "field": "level" + } + } + } + } + ] + } + } + } + } +} diff --git a/pocs/zeroth-playground/compiler/testing/coloured-nets.fixtures.ts b/pocs/zeroth-playground/compiler/testing/coloured-nets.fixtures.ts new file mode 100644 index 00000000..a827bff2 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/coloured-nets.fixtures.ts @@ -0,0 +1,140 @@ +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** The bucket as one module: slots, bindings in arc order and compaction at the end of the step. */ +export const bucketPython = `from zrth import LRA, Bool, Real, Var, expr +from zrth.sugar import Module, X, ite + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Pool_0_present = Var(BOOL) # Pool slot 0 holds a token +Pool_0_x = Var(REAL) +Pool_1_present = Var(BOOL) # Pool slot 1 holds a token +Pool_1_x = Var(REAL) +Done = Var(REAL) + +e_Take = Var(REAL) # exponential draw for Take, each step: -ln(u) / dt + + +class Bucket(Module): + """Stochastic coloured Petri net with 2 places (1 coloured) and 1 transition, dt = 1.0. One next is one step of the net.""" + + def init(self, e_Take): + return True, 2.0, True, 0.5, 0.0 + + def next(self, Pool_0_present, Pool_0_x, Pool_1_present, Pool_1_x, Done, e_Take): + # sweep in order; a firing consumes its input tokens at once + bind_Take_0 = Pool_0_present & (0.006 * Pool_0_x >= X(e_Take)) # Pool[0] + sel_Take_0 = bind_Take_0 + seen_Take = bind_Take_0 + bind_Take_1 = Pool_1_present & (0.006 * Pool_1_x >= X(e_Take)) # Pool[1] + sel_Take_1 = bind_Take_1 & ~seen_Take + seen_Take = seen_Take | bind_Take_1 + take_Take_Pool_0 = sel_Take_0 + take_Take_Pool_1 = sel_Take_1 + fire_Take = seen_Take # Take: Pool -> Done + Pool_0_present = Pool_0_present & ~take_Take_Pool_0 + Pool_1_present = Pool_1_present & ~take_Take_Pool_1 + # end of step: produced tokens land + # Pool: survivors close up in slot order + rank_Pool_1 = ite(Pool_0_present, expr(1.0, theory=LRA, sort=REAL), 0.0) + kept_Pool = rank_Pool_1 + ite(Pool_1_present, expr(1.0, theory=LRA, sort=REAL), 0.0) + next_Pool_0_x = ite(Pool_1_present & (rank_Pool_1 == 0.0), Pool_1_x, Pool_0_x) + next_Pool_1_x = Pool_1_x + Pool_0_x = next_Pool_0_x + Pool_1_x = next_Pool_1_x + Pool_0_present = kept_Pool >= 1.0 + Pool_1_present = kept_Pool >= 2.0 + Done = ite(fire_Take, Done + 1.0, Done) + return Pool_0_present, Pool_0_x, Pool_1_present, Pool_1_x, Done + + +net = Bucket(theory=LRA, ctrl=(Pool_0_present, Pool_0_x, Pool_1_present, Pool_1_x, Done), extl=(e_Take,)) +`; + +/** Balls drawn from a capped coloured pool at a rate that reads each ball. */ +export const bucketIr: PetriNetIr = { + name: "bucket", + kind: "stochastic", + colours: { Ball: { x: "real" } }, + places: { Pool: { colour: "Ball", capacity: 2 }, Done: null }, + marking: { Pool: [{ x: 2 }, { x: 0.5 }] }, + transitions: { + Take: { + inputs: { Pool: null }, + outputs: { Done: null }, + rate: "return 0.6 * (input.Pool[0].x / 100);", + }, + }, +}; + +/** A tank whose level rises by dynamics, read by a guarded alarm. */ +export const boilerIr: PetriNetIr = { + name: "boiler", + kind: "plain", + colours: { Vessel: { level: "real" } }, + dynamics: { + Heat: { + colour: "Vessel", + code: "return tokens.map((t) => ({ level: 0.5 * (10 - t.level) }));", + }, + }, + places: { + Tank: { colour: "Vessel", dynamics: "Heat", capacity: 1 }, + Alarms: null, + }, + marking: { Tank: [{ level: 0 }] }, + transitions: { + Alarm: { + inputs: { Tank: { kind: "read" } }, + outputs: { Alarms: null }, + guard: "return input.Tank[0].level >= 8;", + }, + }, +}; + +/** The boiler's options: a step of a quarter. */ +export const boilerOptions: CompilerOptions = { dt: 0.25 }; + +/** Drones launched at a rate that reads the battery, drained in the air, landed by a guard. */ +export const dronesIr: PetriNetIr = { + name: "drones", + kind: "mixed", + colours: { Drone: { battery: "real", state: { enum: ["idle", "flying"] } } }, + dynamics: { + Drain: { + colour: "Drone", + code: "return tokens.map((d) => ({ battery: -2.5 }));", + }, + }, + places: { + Hangar: { colour: "Drone", capacity: 3 }, + Airborne: { colour: "Drone", capacity: 3, dynamics: "Drain" }, + Sorties: null, + }, + marking: { + Hangar: [ + { battery: 100, state: "idle" }, + { battery: 60, state: "idle" }, + ], + }, + transitions: { + Launch: { + inputs: { Hangar: null }, + outputs: { Airborne: null, Sorties: null }, + rate: "return 0.6 * (input.Hangar[0].battery / 100);", + kernel: + 'const d = input.Hangar[0];\nreturn { Airborne: [{ battery: d.battery, state: "flying" }] };', + }, + Land: { + inputs: { Airborne: null }, + outputs: { Hangar: null }, + guard: 'return input.Airborne[0].battery < 20 || input.Airborne[0].state !== "flying";', + kernel: 'return { Hangar: [{ battery: 100, state: "idle" }] };', + }, + }, +}; + +/** The drones' options: a step of a half. */ +export const dronesOptions: CompilerOptions = { dt: 0.5 }; diff --git a/pocs/zeroth-playground/compiler/testing/compile-net.ts b/pocs/zeroth-playground/compiler/testing/compile-net.ts new file mode 100644 index 00000000..a503968f --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/compile-net.ts @@ -0,0 +1,38 @@ +import { dump } from "js-yaml"; + +import { compile } from "../compile"; + +import type { Compilation } from "../compile"; +import type { PetriNetIr } from "../ir/schema"; +import type { CodeParser } from "../code/code-tree"; +import type { CompilerOptions } from "../options"; + +/** The IR as the YAML text `compile` reads. */ +export function yaml(ir: PetriNetIr): string { + return dump(ir); +} + +/** The net compiled from its YAML text. */ +export function compileNet( + ir: PetriNetIr, + options?: CompilerOptions, + parseCode?: CodeParser, +): Compilation { + return compile(yaml(ir), { options, parseCode }); +} + +/** The main file of a net that compiles, `net.py`; throws with the refusals otherwise. */ +export function pythonOf( + ir: PetriNetIr, + options?: CompilerOptions, + parseCode?: CodeParser, +): string { + const { files, errors } = compileNet(ir, options, parseCode); + const main = files[0]; + if (main === undefined) { + throw new Error( + errors.map(({ item, message }) => `${item.kind} ${item.name}: ${message}`).join("; "), + ); + } + return main.text; +} diff --git a/pocs/zeroth-playground/compiler/testing/fixture-parser.ts b/pocs/zeroth-playground/compiler/testing/fixture-parser.ts new file mode 100644 index 00000000..a34e176e --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/fixture-parser.ts @@ -0,0 +1,27 @@ +import CODE_TREES from "./code-trees.json"; + +import type { CodeFunction } from "../code/code-tree"; +import type { CodeSurface } from "../code/code-tree"; + +/** + * The trees a TypeScript parser made of every code string the tests use, + * captured once with constants folded. They carry no ids, spans or surface, + * which nothing in the compiler reads. + */ +const TREES = CODE_TREES as unknown as Record< + CodeSurface, + Record +>; + +/** + * A code parser for tests: it looks the code string up instead of parsing + * it. A string with no captured tree fails the test, so a new code string + * needs its tree added to code-trees.json. + */ +export function parseCode(code: string, surface: CodeSurface): CodeFunction { + const tree = TREES[surface][code]; + if (tree === undefined) { + throw new Error(`no ${surface} tree is captured for ${JSON.stringify(code)}`); + } + return tree; +} diff --git a/pocs/zeroth-playground/compiler/testing/fork.fixtures.ts b/pocs/zeroth-playground/compiler/testing/fork.fixtures.ts new file mode 100644 index 00000000..e3ccad6f --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/fork.fixtures.ts @@ -0,0 +1,29 @@ +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; + +/** Two transitions take from one place, and a third is in no conflict. */ +export const forkIr: PetriNetIr = { + name: "fork", + kind: "plain", + places: { Pool: null, Left: null, Right: null }, + marking: { Pool: 3 }, + transitions: { + TakeLeft: { inputs: { Pool: null }, outputs: { Left: null } }, + TakeRight: { inputs: { Pool: null }, outputs: { Right: null } }, + Return: { inputs: { Left: null }, outputs: { Pool: null } }, + }, +}; + +/** The fork at rates. */ +export const forkClockedIr: PetriNetIr = { + ...forkIr, + kind: "stochastic", + transitions: { + TakeLeft: { inputs: { Pool: null }, outputs: { Left: null }, rate: 1 }, + TakeRight: { inputs: { Pool: null }, outputs: { Right: null }, rate: 2 }, + Return: { inputs: { Left: null }, outputs: { Pool: null }, rate: 0.5 }, + }, +}; + +/** The options the fork at rates is compiled under: clocks, with the conflict left open. */ +export const forkClockedOptions: CompilerOptions = { rates: "clock", conflicts: "nondet" }; diff --git a/pocs/zeroth-playground/compiler/testing/line-containing.ts b/pocs/zeroth-playground/compiler/testing/line-containing.ts new file mode 100644 index 00000000..c762dfd0 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/line-containing.ts @@ -0,0 +1,8 @@ +/** The 1-based number of the first line of `text` that contains `needle`; throws when none does. */ +export function lineContaining(text: string, needle: string): number { + const index = text.split("\n").findIndex((line) => line.includes(needle)); + if (index === -1) { + throw new Error(`no line contains ${needle}`); + } + return index + 1; +} diff --git a/pocs/zeroth-playground/compiler/testing/random-net.ts b/pocs/zeroth-playground/compiler/testing/random-net.ts new file mode 100644 index 00000000..85b7eba4 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/random-net.ts @@ -0,0 +1,106 @@ +import type { PetriNetIr, PetriNetIrArcs } from "../ir/schema"; +import type { LinearValue } from "./run-linear-graph"; + +const MODULUS = 2 ** 32; + +type Random = { + unit: () => number; + int: (min: number, max: number) => number; + chance: (probability: number) => boolean; +}; + +/** A small deterministic generator, so a failure names the net that broke. */ +function createRandom(seed: number): Random { + let state = seed % MODULUS; + function unit(): number { + state = (state * 1664525 + 1013904223) % MODULUS; + return state / MODULUS; + } + return { + unit, + int: (min, max) => min + Math.floor(unit() * (max - min + 1)), + chance: (probability) => unit() < probability, + }; +} + +/** One input arc in five reads its place and one in five is an inhibitor; the rest move tokens. */ +const INPUT_KINDS = [undefined, undefined, undefined, "read", "inhibitor"] as const; + +function randomArcs( + random: Random, + places: string[], + count: number, + side: "inputs" | "outputs", +): PetriNetIrArcs { + const arcs: PetriNetIrArcs = {}; + for (let i = 0; i < count; i++) { + const place = places[random.int(0, places.length - 1)]; + if (place !== undefined) { + const weight = random.int(1, 2); + const kind = side === "inputs" ? INPUT_KINDS[random.int(0, 4)] : undefined; + arcs[place] = + weight === 1 && kind === undefined + ? null + : { ...(weight === 1 ? {} : { weight }), ...(kind === undefined ? {} : { kind }) }; + } + } + return arcs; +} + +/** + * A plain or stochastic net of two to five places and one to five + * transitions, with weighted, read and inhibitor arcs, capacities and + * controllable transitions, the same for the same seed. + */ +export function randomNet(seed: number, stochastic: boolean): PetriNetIr { + const random = createRandom(seed); + const placeNames = ["A", "B", "C", "D", "E"].slice(0, random.int(2, 5)); + const places: PetriNetIr["places"] = {}; + const marking: Record = {}; + for (const name of placeNames) { + const initial = random.int(0, 3); + if (initial > 0) { + marking[name] = initial; + } + places[name] = random.chance(0.4) ? { capacity: initial + random.int(0, 2) } : null; + } + const transitions: PetriNetIr["transitions"] = {}; + const count = random.int(1, 5); + for (let i = 0; i < count; i++) { + transitions[`T${i}`] = { + ...(random.chance(0.85) + ? { inputs: randomArcs(random, placeNames, random.int(1, 2), "inputs") } + : {}), + ...(random.chance(0.85) + ? { outputs: randomArcs(random, placeNames, random.int(1, 2), "outputs") } + : {}), + ...(stochastic ? { rate: 0.5 + random.unit() * 3 } : {}), + ...(random.chance(0.3) ? { controllable: true as const } : {}), + }; + } + return { + name: `net${seed}`, + kind: stochastic ? "stochastic" : "plain", + places, + ...(Object.keys(marking).length === 0 ? {} : { marking }), + transitions, + }; +} + +function hash(text: string): number { + let value = 0; + for (const char of text) { + value = (value * 31 + char.charCodeAt(0)) % MODULUS; + } + return value; +} + +/** One value per input name and step, the same for every shape of a net. */ +export function inputsFor(seed: number): (step: number, name: string) => LinearValue { + return (step, name) => { + const random = createRandom(seed * 7919 + step * 104729 + hash(name)); + // The first draw of a plain LCG barely moves; skip it. + random.unit(); + return name.startsWith("go_") ? random.chance(0.6) : random.unit(); + }; +} diff --git a/pocs/zeroth-playground/compiler/testing/raw-text.d.ts b/pocs/zeroth-playground/compiler/testing/raw-text.d.ts new file mode 100644 index 00000000..264f55ab --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/raw-text.d.ts @@ -0,0 +1,5 @@ +/** A file read as text through Vite's `?raw` query, as the README test reads the README. */ +declare module "*.md?raw" { + const text: string; + export default text; +} diff --git a/pocs/zeroth-playground/compiler/testing/reference-step.ts b/pocs/zeroth-playground/compiler/testing/reference-step.ts new file mode 100644 index 00000000..b2a03333 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/reference-step.ts @@ -0,0 +1,69 @@ +import { arcKind, arcWeight } from "../ir/accessors"; + +import type { PetriNetIr } from "../ir/schema"; +import type { ResolvedOptions } from "../options"; +import type { LinearValue } from "./run-linear-graph"; + +/** + * One step of the net over the IR, the oracle the lowerings are checked + * against: transitions in record order, enablement on the current count, + * capacity on count plus pending plus the net change, consumption at once, + * production at the end of the step. A read arc needs its weight and an + * inhibitor arc fewer; neither moves a token. The options give the step's + * length and whether a controllable transition waits for its choice. + */ +export function referenceStep( + ir: PetriNetIr, + options: ResolvedOptions, + marking: Record, + inputs: (name: string) => LinearValue, +): Record { + const count = { ...marking }; + const pending: Record = {}; + for (const [name, transition] of Object.entries(ir.transitions)) { + const arcs = Object.entries(transition.inputs ?? {}).map( + ([place, arc]) => [place, arcWeight(arc), arcKind(arc)] as const, + ); + const consumes = arcs.filter(([, , kind]) => kind === "standard"); + const produces = Object.entries(transition.outputs ?? {}).map( + ([place, arc]) => [place, arcWeight(arc)] as const, + ); + const deltas: Record = {}; + for (const [place, weight] of produces) { + deltas[place] = (deltas[place] ?? 0) + weight; + } + for (const [place, weight] of consumes) { + deltas[place] = (deltas[place] ?? 0) - weight; + } + let enabled = arcs.every(([place, weight, kind]) => + kind === "inhibitor" ? (count[place] ?? 0) < weight : (count[place] ?? 0) >= weight, + ); + for (const [place, delta] of Object.entries(deltas)) { + const capacity = ir.places[place]?.capacity; + if (delta > 0 && capacity !== undefined) { + enabled &&= (count[place] ?? 0) + (pending[place] ?? 0) + delta <= capacity; + } + } + if (ir.kind === "stochastic") { + const draw = inputs(`u_${name}`); + const rate = typeof transition.rate === "number" ? transition.rate : 0; + enabled &&= typeof draw === "number" && draw >= Math.exp(-rate * options.dt); + } + if (transition.controllable === true && options.control === "open") { + enabled &&= inputs(`go_${name}`) === true; + } + if (!enabled) { + continue; + } + for (const [place, weight] of consumes) { + count[place] = (count[place] ?? 0) - weight; + } + for (const [place, weight] of produces) { + pending[place] = (pending[place] ?? 0) + weight; + } + } + for (const place of Object.keys(ir.places)) { + count[place] = (count[place] ?? 0) + (pending[place] ?? 0); + } + return count; +} diff --git a/pocs/zeroth-playground/compiler/testing/run-linear-graph.ts b/pocs/zeroth-playground/compiler/testing/run-linear-graph.ts new file mode 100644 index 00000000..d0ad67d2 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/run-linear-graph.ts @@ -0,0 +1,318 @@ +import { pickName } from "../lower/names"; + +import type { LinearExpr, LinearModule, LinearGraph } from "../graph/linear-graph"; + +import type { ModuleGraph } from "../graph/module-graph"; + +/** + * Runs a reactive module graph the way Zeroth's evaluator runs a composed + * module: every round, each module's `next` is evaluated once, in an + * order where a module comes after the drivers of the variables it awaits, + * and the next values are latched together at the end of the round. The + * inputs are read from the harness each round; a pick the harness leaves + * undriven is true, the deterministic sweep. + * + * Test support only: the tests use it to check that two shapes of the same + * net produce the same trace. An SPN graph runs clocks in continuous time, + * which the rounds do not model, so it is refused. + */ + +/** What the name coiner writes before a transition's name to name its pick. */ +const PICK_PREFIX = pickName(""); + +function linearGraph(graph: ModuleGraph): LinearGraph { + if (graph.language === "spn") { + throw new Error( + "an SPN graph runs clocks in continuous time, which the interpreter does not model; lower with rates: coin to interpret it", + ); + } + return graph; +} + +export type LinearValue = number | boolean; + +export type RunLinearGraphOptions = { + steps: number; + /** + * The value of an input for a round; rounds count from 1. `undefined` + * lets a pick fall to `true`; every other input needs a value. + */ + inputs: (step: number, name: string) => LinearValue | undefined; +}; + +/** The variables' values after `init`, then after each round. */ +export type Trajectory = Record[]; + +function driversOf(graph: LinearGraph): Map { + const drivers = new Map(); + for (const module of graph.modules) { + for (const name of module.ctrl) { + const other = drivers.get(name); + if (other !== undefined) { + throw new Error(`${name} is driven by both ${other.className} and ${module.className}`); + } + drivers.set(name, module); + } + } + for (const variable of graph.variables) { + const driver = drivers.get(variable.name); + if (variable.role === "input" && driver !== undefined) { + throw new Error(`input ${variable.name} is driven by ${driver.className}`); + } + if (variable.role !== "input" && driver === undefined) { + throw new Error(`${variable.name} has no driver`); + } + } + return drivers; +} + +/** The names of the variables an expression awaits (`next` references). */ +function awaitedNames(expr: LinearExpr, into: Set): void { + switch (expr.kind) { + case "ref": + if (expr.next) { + into.add(expr.name); + } + return; + case "num": + case "bool": + return; + case "binary": + awaitedNames(expr.left, into); + awaitedNames(expr.right, into); + return; + case "ite": + awaitedNames(expr.condition, into); + awaitedNames(expr.thenBranch, into); + awaitedNames(expr.elseBranch, into); + return; + case "not": + case "scale": + case "relu": + awaitedNames(expr.operand, into); + } +} + +/** Every variable a module awaits, over its `next` and `returns`. */ +export function moduleAwaits(module: LinearModule): Set { + const names = new Set(); + for (const statement of module.next) { + if (statement.kind === "assign") { + awaitedNames(statement.expr, names); + } + } + for (const expr of module.returns) { + awaitedNames(expr, names); + } + return names; +} + +/** + * The modules in an order every await can be met in: a module after the + * drivers of the variables it awaits. Throws when the awaits form a cycle, + * which composition would reject too. + */ +export function orderLinearModules(lowered: ModuleGraph): LinearModule[] { + const graph = linearGraph(lowered); + const drivers = driversOf(graph); + const inputs = new Set( + graph.variables + .filter((variable) => variable.role === "input") + .map((variable) => variable.name), + ); + const waitsOn = new Map>(); + for (const module of graph.modules) { + const upstream = new Set(); + for (const name of moduleAwaits(module)) { + if (inputs.has(name)) { + continue; + } + const driver = drivers.get(name); + if (driver === undefined) { + throw new Error(`${module.className} awaits ${name}, which nothing drives`); + } + if (driver !== module) { + upstream.add(driver); + } + } + waitsOn.set(module, upstream); + } + const ordered: LinearModule[] = []; + const placed = new Set(); + let remaining = graph.modules; + while (remaining.length > 0) { + const ready = remaining.filter((module) => + [...(waitsOn.get(module) ?? [])].every((upstream) => placed.has(upstream)), + ); + if (ready.length === 0) { + throw new Error( + `the awaits form a cycle through ${remaining.map((module) => module.className).join(", ")}`, + ); + } + for (const module of ready) { + ordered.push(module); + placed.add(module); + } + remaining = remaining.filter((module) => !placed.has(module)); + } + return ordered; +} + +type Scope = { + /** Locals of the module being evaluated, over the latched values it reads. */ + locals: Map; + /** A variable's next value: an input's for this round, or its driver's. */ + awaited: (name: string) => LinearValue; +}; + +function asNumber(value: LinearValue, where: string): number { + if (typeof value !== "number") { + throw new TypeError(`${where} expects a number, got ${String(value)}`); + } + return value; +} + +function asBoolean(value: LinearValue, where: string): boolean { + if (typeof value !== "boolean") { + throw new TypeError(`${where} expects a Bool, got ${String(value)}`); + } + return value; +} + +function evaluate(node: LinearExpr, scope: Scope): LinearValue { + switch (node.kind) { + case "ref": { + if (node.next) { + return scope.awaited(node.name); + } + const value = scope.locals.get(node.name); + if (value === undefined) { + throw new Error(`${node.name} is read but not in scope`); + } + return value; + } + case "num": + return node.value; + case "bool": + return node.value; + case "binary": { + const left = evaluate(node.left, scope); + const right = evaluate(node.right, scope); + switch (node.op) { + case "+": + return asNumber(left, "+") + asNumber(right, "+"); + case "-": + return asNumber(left, "-") - asNumber(right, "-"); + case "<": + return asNumber(left, "<") < asNumber(right, "<"); + case "<=": + return asNumber(left, "<=") <= asNumber(right, "<="); + case ">": + return asNumber(left, ">") > asNumber(right, ">"); + case ">=": + return asNumber(left, ">=") >= asNumber(right, ">="); + case "==": + return left === right; + case "!=": + return left !== right; + case "&": + return asBoolean(left, "&") && asBoolean(right, "&"); + case "|": + return asBoolean(left, "|") || asBoolean(right, "|"); + } + break; + } + case "ite": + return asBoolean(evaluate(node.condition, scope), "ite") + ? evaluate(node.thenBranch, scope) + : evaluate(node.elseBranch, scope); + case "not": + return !asBoolean(evaluate(node.operand, scope), "~"); + case "scale": + return node.factor * asNumber(evaluate(node.operand, scope), "*"); + case "relu": + return Math.max(0, asNumber(evaluate(node.operand, scope), "relu")); + } +} + +function noAwait(name: string): LinearValue { + throw new Error(`${name} is awaited where nothing is computed yet`); +} + +export function runLinearGraph( + lowered: ModuleGraph, + { steps, inputs }: RunLinearGraphOptions, +): Trajectory { + const graph = linearGraph(lowered); + const ordered = orderLinearModules(graph); + const inputNames = new Set( + graph.variables + .filter((variable) => variable.role === "input") + .map((variable) => variable.name), + ); + const state = new Map(); + for (const module of ordered) { + module.ctrl.forEach((name, index) => { + const init = module.init[index]; + if (init === undefined) { + throw new Error(`${module.className} has no init for ${name}`); + } + state.set(name, evaluate(init, { locals: new Map(), awaited: noAwait })); + }); + } + function snapshot(): Record { + return Object.fromEntries(state); + } + const trace: Trajectory = [snapshot()]; + for (let step = 1; step <= steps; step++) { + const nextValues = new Map(); + function awaited(name: string): LinearValue { + if (inputNames.has(name)) { + const value = inputs(step, name); + if (value !== undefined) { + return value; + } + if (name.startsWith(PICK_PREFIX)) { + return true; + } + throw new Error(`${name} has no value for round ${step}`); + } + const value = nextValues.get(name); + if (value === undefined) { + throw new Error(`${name} is awaited before its driver ran`); + } + return value; + } + for (const module of ordered) { + const locals = new Map(); + for (const name of [...module.ctrl, ...module.extl]) { + if (inputNames.has(name)) { + continue; + } + const value = state.get(name); + if (value === undefined) { + throw new Error(`${module.className} reads ${name}, which has no value`); + } + locals.set(name, value); + } + const scope: Scope = { locals, awaited }; + for (const statement of module.next) { + if (statement.kind === "assign") { + locals.set(statement.target, evaluate(statement.expr, scope)); + } + } + module.ctrl.forEach((name, index) => { + const value = module.returns[index]; + if (value === undefined) { + throw new Error(`${module.className} returns nothing for ${name}`); + } + nextValues.set(name, evaluate(value, scope)); + }); + } + for (const [name, value] of nextValues) { + state.set(name, value); + } + trace.push(snapshot()); + } + return trace; +} diff --git a/pocs/zeroth-playground/compiler/testing/small-nets.fixtures.ts b/pocs/zeroth-playground/compiler/testing/small-nets.fixtures.ts new file mode 100644 index 00000000..772c52f2 --- /dev/null +++ b/pocs/zeroth-playground/compiler/testing/small-nets.fixtures.ts @@ -0,0 +1,77 @@ +import type { PetriNetIr } from "../ir/schema"; + +/** The small nets the tests share. */ + +/** One token going round two places. */ +export const cycle: PetriNetIr = { + name: "cycle", + kind: "plain", + places: { A: null, B: null }, + marking: { A: 1 }, + transitions: { + Go: { inputs: { A: null }, outputs: { B: null } }, + Back: { inputs: { B: null }, outputs: { A: null } }, + }, +}; + +/** Two producers into a buffer that holds at most 2, and a taker. */ +export const capacity: PetriNetIr = { + name: "capacity", + kind: "plain", + places: { + LeftSource: null, + RightSource: null, + Buffer: { capacity: 2 }, + Sink: null, + }, + marking: { LeftSource: 5, RightSource: 5 }, + transitions: { + PutLeft: { inputs: { LeftSource: null }, outputs: { Buffer: null } }, + PutRight: { inputs: { RightSource: null }, outputs: { Buffer: null } }, + Take: { inputs: { Buffer: null }, outputs: { Sink: null } }, + }, +}; + +/** Arrivals into a capped queue and a server, both at a rate. */ +export const queue: PetriNetIr = { + name: "queue", + kind: "stochastic", + places: { Waiting: { capacity: 4 }, Served: null }, + transitions: { + Arrive: { outputs: { Waiting: null }, rate: 2.5 }, + Serve: { inputs: { Waiting: null }, outputs: { Served: null }, rate: 3 }, + }, +}; + +/** Arrivals into a queue served two at a time, with a controllable server. */ +export const servedQueue: PetriNetIr = { + name: "queue", + kind: "stochastic", + places: { Waiting: null, Served: { capacity: 5 } }, + marking: { Waiting: 2 }, + transitions: { + Arrive: { outputs: { Waiting: null }, rate: 2 }, + Serve: { + inputs: { Waiting: { weight: 2 } }, + outputs: { Served: null }, + rate: 1.5, + controllable: true, + }, + }, +}; + +/** Two transitions sharing Pool, the first one controllable and taking two tokens. */ +export const conflict: PetriNetIr = { + name: "conflict", + kind: "plain", + places: { Pool: null, Left: null, Right: null }, + marking: { Pool: 3 }, + transitions: { + TakeLeft: { + inputs: { Pool: { weight: 2 } }, + outputs: { Left: null }, + controllable: true, + }, + TakeRight: { inputs: { Pool: null }, outputs: { Right: null } }, + }, +}; diff --git a/pocs/zeroth-playground/compiler/trace/describe-item.ts b/pocs/zeroth-playground/compiler/trace/describe-item.ts new file mode 100644 index 00000000..94ac3dc0 --- /dev/null +++ b/pocs/zeroth-playground/compiler/trace/describe-item.ts @@ -0,0 +1,73 @@ +import { arcKind, arcWeight } from "../ir/accessors"; + +import type { PetriNetIr, PetriNetIrArc } from "../ir/schema"; + +/** The words for an arc, a transition and a place, shared by the IR and Python traces. */ + +export function arcText(arc: PetriNetIrArc): string { + const weight = arcWeight(arc); + const kind = arcKind(arc); + const tokens = weight === 1 ? "one token" : `${weight} tokens`; + return kind === "standard" + ? tokens + : kind === "read" + ? `reads ${tokens} and leaves them` + : `fires only with fewer than ${weight} token${weight === 1 ? "" : "s"}`; +} + +/** What a transition takes and adds, and when it fires. */ +export function transitionWhy(ir: PetriNetIr, name: string, clocks: boolean): string { + const transition = ir.transitions[name]; + if (transition === undefined) { + return ""; + } + const inputs = Object.keys(transition.inputs ?? {}); + const outputs = Object.keys(transition.outputs ?? {}); + const parts = [ + inputs.length === 0 ? "takes nothing" : `takes from ${inputs.join(", ")}`, + outputs.length === 0 ? "adds nothing" : `adds to ${outputs.join(", ")}`, + ]; + if (transition.rate !== undefined) { + parts.push( + typeof transition.rate === "number" + ? clocks + ? `fires at rate ${transition.rate}, a clock armed with exp(${transition.rate}) each time it fires` + : `fires at rate ${transition.rate}, tested over dt each step` + : "fires at a rate its tokens decide, tested over dt each step", + ); + } else if (transition.guard !== undefined) { + parts.push("fires when enabled and its guard holds"); + } else { + parts.push("fires whenever enabled"); + } + if (transition.controllable === true) { + parts.push("controllable"); + } + return `${parts.join("; ")}.`; +} + +/** What a place starts with, holds and carries. */ +export function placeWhy(ir: PetriNetIr, name: string): string { + const place = ir.places[name]; + const parts: string[] = []; + const initial = ir.marking?.[name]; + if (initial !== undefined) { + parts.push( + typeof initial === "number" + ? `starts with ${initial} token${initial === 1 ? "" : "s"}` + : `starts with ${initial.length} token${initial.length === 1 ? "" : "s"}`, + ); + } else { + parts.push("starts empty"); + } + if (place?.capacity !== undefined) { + parts.push(`holds at most ${place.capacity}`); + } + if (place?.colour !== undefined) { + parts.push(`tokens are ${place.colour} records`); + } + if (place?.dynamics !== undefined) { + parts.push(`its tokens move by ${place.dynamics}`); + } + return `${parts.join("; ")}.`; +} diff --git a/pocs/zeroth-playground/compiler/trace/provenance.test.ts b/pocs/zeroth-playground/compiler/trace/provenance.test.ts new file mode 100644 index 00000000..0e6091f8 --- /dev/null +++ b/pocs/zeroth-playground/compiler/trace/provenance.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { yaml } from "../testing/compile-net"; +import { lineContaining } from "../testing/line-containing"; +import { cycle } from "../testing/small-nets.fixtures"; +import { compile } from "../compile"; +import { linesOfItem, matchingLines, provenanceAt } from "./provenance"; + +describe("provenanceAt", () => { + it("returns null off every range", () => { + // GIVEN an empty trace + // WHEN a line is looked up + const provenance = provenanceAt([], 3); + // THEN there is nothing there + expect(provenance).toBeNull(); + }); +}); + +describe("matchingLines", () => { + const irText = yaml(cycle); + const { irTrace, files } = compile(irText); + const python = files[0]; + if (python === undefined) { + throw new Error("the cycle compiles"); + } + + it("pairs ranges by net item first, then by IR path prefix", () => { + // GIVEN a small trace of four ranges + const trace = [ + { + startLine: 1, + endLine: 1, + provenance: { what: "a", ir: "marking.A", source: { kind: "place" as const, name: "A" } }, + }, + { + startLine: 2, + endLine: 2, + provenance: { what: "b", source: { kind: "transition" as const, name: "A" } }, + }, + { startLine: 3, endLine: 4, provenance: { what: "c", ir: "places" } }, + { startLine: 4, endLine: 4, provenance: { what: "d", ir: "places.B.capacity" } }, + ]; + // WHEN ranges are matched against a place, a section and another section + const place = matchingLines(trace, { what: "x", source: { kind: "place", name: "A" } }); + const section = matchingLines(trace, { what: "x", ir: "places.B" }); + const other = matchingLines(trace, { what: "x", ir: "transitions" }); + // THEN a source matches its own item alone, and paths match by prefix among ranges without one + expect(place).toEqual([{ startLine: 1, endLine: 1 }]); + expect(section).toEqual([ + { startLine: 3, endLine: 4 }, + { startLine: 4, endLine: 4 }, + ]); + expect(other).toEqual([]); + }); + + it("lights the transition's Python from a hover on its IR entry", () => { + // GIVEN a hover on the line of the transition Go in the IR + const hovered = provenanceAt(irTrace, lineContaining(irText, " Go:")); + expect(hovered?.source).toEqual({ kind: "transition", name: "Go" }); + if (hovered === null) { + return; + } + // WHEN the Python lines that match it are found + const texts = matchingLines(python.trace, hovered).map(({ startLine }) => + python.text.split("\n")[startLine - 1]?.trim(), + ); + // THEN its firing line is among them, and the landing in B belongs to the place + expect(texts).toContain("fire_Go = A >= 1 # Go: A -> B"); + expect( + provenanceAt(python.trace, lineContaining(python.text, "B = ite(fire_Go, B + 1, B)"))?.source, + ).toEqual({ + kind: "place", + name: "B", + }); + }); + + it("lights the IR lines of a place from its Python variable, as a hover on the place itself would", () => { + // GIVEN a hover on the Python declaration of A + const hovered = provenanceAt(python.trace, lineContaining(python.text, "A = Var(INT)")); + expect(hovered?.source).toEqual({ kind: "place", name: "A" }); + if (hovered === null) { + return; + } + // WHEN the IR lines that match it are found + const lines = matchingLines(irTrace, hovered); + // THEN they are the place's own ranges: its entry, its marking and its arcs + expect(lines.map(({ startLine }) => irText.split("\n")[startLine - 1]?.trim())).toEqual( + expect.arrayContaining(["A: null", "A: 1"]), + ); + expect(linesOfItem(irTrace, { kind: "place", name: "A" })).toEqual(lines); + }); +}); diff --git a/pocs/zeroth-playground/compiler/trace/provenance.ts b/pocs/zeroth-playground/compiler/trace/provenance.ts new file mode 100644 index 00000000..a88b4be2 --- /dev/null +++ b/pocs/zeroth-playground/compiler/trace/provenance.ts @@ -0,0 +1,93 @@ +import { sameItem } from "../ir/net-item"; + +import type { NetItem } from "../ir/net-item"; + +/** + * A line trace over the IR text and the generated Python: which lines + * belong to what, why the compiler wrote them, and the net item they come + * from. A reader shows a range's record on hover and lights its item + * wherever else it is drawn. Ranges nest, section over entry over field, so a line's + * record is the innermost range holding it. + */ + +export type Provenance = { + /** What the lines are, as a short noun phrase. */ + what: string; + /** Why they are there, when the lines do not say it themselves. */ + why?: string; + /** The IR path the lines render or compile from, `transitions.Serve.rate`. */ + ir?: string; + source?: NetItem; +}; + +/** 1-based, inclusive. */ +export type LineRange = { + startLine: number; + endLine: number; +}; + +export type TraceRange = LineRange & { + provenance: Provenance; +}; + +export type Trace = TraceRange[]; + +/** The innermost range holding the line, or `null` off every range. */ +export function provenanceAt(trace: Trace, line: number): Provenance | null { + let best: TraceRange | null = null; + for (const range of trace) { + if (line < range.startLine || line > range.endLine) { + continue; + } + if (best === null || range.endLine - range.startLine < best.endLine - best.startLine) { + best = range; + } + } + return best?.provenance ?? null; +} + +// -- Matching ---------------------------------------------------------------- + +/** + * When two traced ranges belong together: their provenance names the same + * net item, or, for lines without one, their IR paths share a prefix. The + * rule behind cross-highlighting: a hover on one side lights the ranges on + * the other side that match. + */ + +function sharesIrPrefix(a: string, b: string): boolean { + return a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`); +} + +function matches(a: Provenance, b: Provenance): boolean { + if (a.source !== undefined && b.source !== undefined) { + return sameItem(a.source, b.source); + } + if (a.source === undefined && b.source === undefined) { + return a.ir !== undefined && b.ir !== undefined && sharesIrPrefix(a.ir, b.ir); + } + return false; +} + +function lineRange({ startLine, endLine }: TraceRange): LineRange { + return { startLine, endLine }; +} + +/** The ranges in the trace that belong with the provenance. */ +export function matchingLines(trace: Trace, provenance: Provenance): LineRange[] { + return trace.filter((range) => matches(range.provenance, provenance)).map(lineRange); +} + +/** The ranges of one net item, for a hover that starts on the net itself. */ +export function linesOfItem(trace: Trace, item: NetItem): LineRange[] { + return trace + .filter( + ({ provenance }) => provenance.source !== undefined && sameItem(provenance.source, item), + ) + .map(lineRange); +} + +/** The first line of the item's first range, or `undefined` when no range is its own. */ +export function firstLineOf(trace: Trace, item: NetItem): number | undefined { + return linesOfItem(trace, item)[0]?.startLine; +} diff --git a/pocs/zeroth-playground/compiler/trace/trace-ir.test.ts b/pocs/zeroth-playground/compiler/trace/trace-ir.test.ts new file mode 100644 index 00000000..354ec4f0 --- /dev/null +++ b/pocs/zeroth-playground/compiler/trace/trace-ir.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +import { lineContaining } from "../testing/line-containing"; +import { servedQueue } from "../testing/small-nets.fixtures"; +import { provenanceAt } from "./provenance"; +import { traceIr } from "./trace-ir"; + +import type { PetriNetIr } from "../ir/schema"; + +const QUEUE_TEXT = `name: queue +kind: stochastic + +places: + Waiting: + Served: + capacity: 5 + +marking: + Waiting: 2 + +transitions: + Arrive: + outputs: + Waiting: + rate: 2 + Serve: + inputs: + Waiting: + weight: 2 + outputs: + Served: + rate: 1.5 + controllable: true +`; + +describe("traceIr", () => { + const trace = traceIr(servedQueue, QUEUE_TEXT); + + it("describes a transition, its fields and its arcs from the innermost range", () => { + // GIVEN the queue's IR text and its trace + // WHEN the lines of Serve, its rate and its weighted arc are looked up + const transition = provenanceAt(trace, lineContaining(QUEUE_TEXT, " Serve:")); + const rate = provenanceAt(trace, lineContaining(QUEUE_TEXT, " rate: 1.5")); + const arc = provenanceAt(trace, lineContaining(QUEUE_TEXT, " weight: 2")); + // THEN each gets the innermost range's record + expect(transition).toEqual({ + what: "Transition Serve", + why: "takes from Waiting; adds to Served; fires at rate 1.5, tested over dt each step; controllable.", + ir: "transitions.Serve", + source: { kind: "transition", name: "Serve" }, + }); + expect(rate).toMatchObject({ + what: "Serve's firing rate", + ir: "transitions.Serve.rate", + }); + expect(arc).toMatchObject({ + what: "Arc from Waiting into Serve: 2 tokens", + ir: "transitions.Serve.inputs.Waiting", + source: { kind: "place", name: "Waiting" }, + }); + }); + + it("describes places, the marking, the kind and a section", () => { + // GIVEN the queue's IR text and its trace + // WHEN a place, a marking entry, the kind and a section head are looked up + const place = provenanceAt(trace, lineContaining(QUEUE_TEXT, " Served:")); + const marking = provenanceAt(trace, lineContaining(QUEUE_TEXT, " Waiting: 2")); + const kind = provenanceAt(trace, lineContaining(QUEUE_TEXT, "kind: stochastic")); + const section = provenanceAt(trace, lineContaining(QUEUE_TEXT, "places:")); + // THEN each says what it is + expect(place).toMatchObject({ + what: "Place Served", + why: "starts empty; holds at most 5.", + source: { kind: "place", name: "Served" }, + }); + expect(marking).toMatchObject({ + what: "Initial tokens of Waiting", + ir: "marking.Waiting", + }); + expect(kind).toMatchObject({ what: "A stochastic net" }); + expect(section?.what).toBe("The places, in the order the net lists them"); + }); + + it("gives nothing to a section the IR does not have", () => { + // GIVEN the queue's text with a section that is not part of the IR + const text = `${QUEUE_TEXT}\nzeroth:\n shape: modular\n`; + // WHEN it is traced + const withSection = traceIr(servedQueue, text); + // THEN its lines are off every range + expect(provenanceAt(withSection, lineContaining(text, " shape: modular"))).toBeNull(); + }); +}); + +describe("traceIr quoting", () => { + it("reads a key the dumper quotes", () => { + // GIVEN a net with a place named On, which YAML 1.1 reads as a Boolean + const toggle: PetriNetIr = { + name: "toggle", + kind: "stochastic", + places: { On: null, Off: null }, + marking: { On: 1 }, + transitions: { + Flip: { inputs: { On: null }, outputs: { Off: null }, rate: 1 }, + }, + }; + const text = [ + "name: toggle", + "kind: stochastic", + "", + "places:", + " 'On':", + " 'Off':", + "", + "marking:", + " 'On': 1", + "", + "transitions:", + " Flip:", + " inputs:", + " 'On':", + " outputs:", + " 'Off':", + " rate: 1", + "", + ].join("\n"); + // WHEN its IR text is traced + const trace = traceIr(toggle, text); + // THEN the quoted key is the place On + expect(provenanceAt(trace, lineContaining(text, " 'On':"))).toMatchObject({ + what: "Place On", + ir: "places.On", + source: { kind: "place", name: "On" }, + }); + }); + + it("reads the lines of a block scalar as text, not as keys", () => { + // GIVEN the queue with a kernel whose lines look like keys + const text = QUEUE_TEXT.replace( + " rate: 1.5\n", + " rate: 1.5\n kernel: |\n Air: 1\n Waiting: 2\n", + ); + // WHEN it is traced + const trace = traceIr(servedQueue, text); + // THEN the kernel's lines belong to the kernel + const kernel = provenanceAt(trace, lineContaining(text, " kernel: |")); + expect(provenanceAt(trace, lineContaining(text, " Air: 1"))).toEqual(kernel); + expect(provenanceAt(trace, lineContaining(text, " Waiting: 2"))).toEqual(kernel); + }); +}); + +describe("traceIr under clock rates", () => { + it("reads a rate as a clock armed with it", () => { + // GIVEN the birth-death net under clock rates + const birthDeath: PetriNetIr = { + name: "birth_death", + kind: "stochastic", + places: { Population: null }, + transitions: { + Birth: { outputs: { Population: null }, rate: 2 }, + Death: { inputs: { Population: null }, rate: 1 }, + }, + }; + const text = [ + "name: birth_death", + "kind: stochastic", + "", + "places:", + " Population:", + "", + "transitions:", + " Birth:", + " outputs:", + " Population:", + " rate: 2", + " Death:", + " inputs:", + " Population:", + " rate: 1", + "", + ].join("\n"); + // WHEN its IR text is traced, and a rate, a transition and the kind are looked up + const trace = traceIr(birthDeath, text, { rates: "clock" }); + const rate = provenanceAt(trace, lineContaining(text, " rate: 2")); + const transition = provenanceAt(trace, lineContaining(text, " Death:")); + const kind = provenanceAt(trace, lineContaining(text, "kind: stochastic")); + // THEN each reads a rate as a clock armed with it + expect(rate).toMatchObject({ + what: "Birth's firing rate", + why: "Arms an exponential clock with this rate when the transition fires.", + }); + expect(transition?.why).toBe( + "takes from Population; adds nothing; fires at rate 1, a clock armed with exp(1) each time it fires.", + ); + expect(kind?.why).toBe( + "Every transition has a rate; each arms an exponential clock and fires when it expires.", + ); + }); +}); diff --git a/pocs/zeroth-playground/compiler/trace/trace-ir.ts b/pocs/zeroth-playground/compiler/trace/trace-ir.ts new file mode 100644 index 00000000..5a8e9990 --- /dev/null +++ b/pocs/zeroth-playground/compiler/trace/trace-ir.ts @@ -0,0 +1,191 @@ +import { yamlKeys } from "../ir/yaml-keys"; +import { resolveOptions } from "../options"; +import { arcText, placeWhy, transitionWhy } from "./describe-item"; + +import type { PetriNetIr } from "../ir/schema"; +import type { CompilerOptions } from "../options"; +import type { Provenance, Trace } from "./provenance"; + +/** + * The trace over the IR text: one range per section, entry, field and arc, + * each over the lines `yamlKeys` gives its key. Rates read as clocks or as + * coins, as the options say. + */ + +const KIND_WHY: Record = { + plain: "Every transition fires whenever it is enabled.", + stochastic: "Every transition has a rate; a step tests each enabled one against a draw.", + mixed: "Some transitions fire whenever enabled, others by rate.", +}; + +const KIND_WHY_CLOCKS: Record = { + ...KIND_WHY, + stochastic: + "Every transition has a rate; each arms an exponential clock and fires when it expires.", +}; + +function describeIrPath(ir: PetriNetIr, path: string[], clocks: boolean): Provenance | null { + const [section, entry, field, sub] = path; + const irPath = path.join("."); + switch (section) { + case "name": + return { + what: "The net's name, as the module calls itself", + ir: irPath, + source: { kind: "net", name: ir.name }, + }; + case "description": + return { what: "The net's description", ir: irPath }; + case "kind": + return { + what: `A ${ir.kind} net`, + why: (clocks ? KIND_WHY_CLOCKS : KIND_WHY)[ir.kind], + ir: irPath, + }; + case "colours": + if (entry === undefined) { + return { + what: "The token colours, each with its attributes and their types", + ir: irPath, + }; + } + return field === undefined + ? { + what: `Colour ${entry}`, + why: "A closed string attribute lists the values the net can write.", + ir: irPath, + source: { kind: "colour", name: entry }, + } + : { + what: `Attribute ${field} of ${entry}`, + ir: irPath, + source: { kind: "colour", name: entry }, + }; + case "dynamics": + if (entry === undefined) { + return { + what: "The differential equations, each with the colour it moves", + ir: irPath, + }; + } + return { + what: + field === "code" + ? `The equation ${entry}, as the body you wrote` + : field === "colour" + ? `The colour ${entry} moves` + : `Dynamics ${entry}`, + why: + field === undefined + ? "Applied as one Euler step of dt before the transitions fire." + : undefined, + ir: irPath, + source: { kind: "dynamics", name: entry }, + }; + case "places": + if (entry === undefined) { + return { + what: "The places, in the order the net lists them", + ir: irPath, + }; + } + if (field === undefined) { + return { + what: `Place ${entry}`, + why: placeWhy(ir, entry), + ir: irPath, + source: { kind: "place", name: entry }, + }; + } + return { + what: + field === "capacity" + ? `${entry} holds at most ${ir.places[entry]?.capacity ?? ""} tokens` + : field === "colour" + ? `${entry}'s tokens are ${ir.places[entry]?.colour ?? ""} records` + : `${entry}'s tokens move by ${ir.places[entry]?.dynamics ?? ""}`, + ir: irPath, + source: { kind: "place", name: entry }, + }; + case "marking": + if (entry === undefined) { + return { + what: "The initial marking: the tokens each place starts with", + why: "A place absent here starts empty.", + ir: irPath, + }; + } + return { + what: field === undefined ? `Initial tokens of ${entry}` : `A token ${entry} starts with`, + ir: irPath, + source: { kind: "place", name: entry }, + }; + case "transitions": + if (entry === undefined) { + return { + what: "The transitions, in the order a step sweeps them", + ir: irPath, + }; + } + if (field === undefined) { + return { + what: `Transition ${entry}`, + why: transitionWhy(ir, entry, clocks), + ir: irPath, + source: { kind: "transition", name: entry }, + }; + } + if (field === "inputs" || field === "outputs") { + if (sub === undefined) { + return { + what: field === "inputs" ? `The arcs into ${entry}` : `The arcs out of ${entry}`, + ir: irPath, + source: { kind: "transition", name: entry }, + }; + } + const arc = ir.transitions[entry]?.[field]?.[sub] ?? null; + return { + what: + field === "inputs" + ? `Arc from ${sub} into ${entry}: ${arcText(arc)}` + : `Arc from ${entry} into ${sub}: ${arcText(arc)}`, + // The arc's weight and kind lines describe the arc, not themselves. + ir: path.slice(0, 4).join("."), + source: { kind: "place", name: sub }, + }; + } + return { + what: + field === "rate" + ? `${entry}'s firing rate` + : field === "guard" + ? `${entry}'s guard, as the body you wrote` + : field === "kernel" + ? `${entry}'s kernel: the tokens it produces, as the body you wrote` + : field === "controllable" + ? `${entry} is marked controllable in its metadata` + : `${field} of ${entry}`, + why: + field === "rate" + ? clocks + ? "Arms an exponential clock with this rate when the transition fires." + : "Tested over dt each step; code when the rate reads its tokens." + : field === "guard" + ? "Present when the condition reads its tokens; a constant condition is folded away." + : undefined, + ir: irPath, + source: { kind: "transition", name: entry }, + }; + default: + return null; + } +} + +/** Traces the IR text: one range per section, entry, field and arc. */ +export function traceIr(ir: PetriNetIr, text: string, options: CompilerOptions = {}): Trace { + const clocks = resolveOptions(options).rates === "clock"; + return yamlKeys(text).flatMap(({ path, line, endLine }) => { + const provenance = describeIrPath(ir, path, clocks); + return provenance === null ? [] : [{ startLine: line, endLine, provenance }]; + }); +} diff --git a/pocs/zeroth-playground/compiler/tsconfig.json b/pocs/zeroth-playground/compiler/tsconfig.json new file mode 100644 index 00000000..905c7412 --- /dev/null +++ b/pocs/zeroth-playground/compiler/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { "rootDir": ".", "lib": ["ES2023"], "types": [], "jsx": "preserve" }, + "include": ["."], + // The boundary test lists this folder through Vite's import.meta.glob, and the scripts run + // under Node, so the root tsconfig, which loads Vite's and Node's types, checks them instead. + "exclude": ["boundary.test.ts", "scripts"] +} diff --git a/pocs/zeroth-playground/examples/README.md b/pocs/zeroth-playground/examples/README.md new file mode 100644 index 00000000..3338c73e --- /dev/null +++ b/pocs/zeroth-playground/examples/README.md @@ -0,0 +1,114 @@ +# Examples + +One folder per Petri net: its IR document, the compiler options it opens with, and a documentation page. The playground's picker lists the examples marked `listed`, and the tests compile every one. + +## The ladder + +The examples climb a ladder of rungs, from the plainest net up. `LADDER` in `catalog.ts` defines the rungs: + +| Rung | Holds | +| --- | --- | +| `steps` | plain nets: how one step of the net becomes modules | +| `rates` | stochastic nets: coins tested each step, or clocks in continuous time | +| `colours` | coloured tokens, dynamics and kernels; refused in the playground, which has no code parser, and unlisted | + +Each example tackles one feature and is named by it, then by the net it uses: "Rates as coins", using Queue. The picker lists the listed examples rung by rung, sorted by `order`, and skips a rung with none. Put a new example where its feature builds on the ones before it. + +## One folder per example + +``` +queue/ + meta.ts what the picker and the page header show, the rung, the order, the options + net.pn.yaml the IR document + page.mdx the documentation page + components/coin-draws.tsx a drawing only this page uses (optional) + components/coin-draws.css its styles, imported by the component +``` + +The folder name is the id: the picker's key, the page's key and the URL hash, `#queue`. `catalog.ts` collects the folders with `import.meta.glob`, so nothing else registers an example. + +`meta.ts` default-exports an `ExampleMeta`, defined in `catalog.ts`: + +| Field | Value | +| --- | --- | +| `feature` | what the example tackles: its name in the picker and its page heading | +| `title` | the net's name, shown above the heading | +| `summary` | one line on what it shows, in the picker | +| `rung` | `steps`, `rates` or `colours` | +| `order` | its place up the ladder; examples sort by it, in steps of 10 | +| `listed` | `false` keeps it out of the picker; its hash still opens it | +| `options` | the compiler options it opens with | + +## Options + +The IR carries no options. An example's options sit in `meta.ts`, as a `CompilerOptions` object, and the playground's options panel edits them from there. Reset restores them. + +- An example opens in the modular shape wherever that shape compiles its net: `{ shape: "modular" }`. Coloured nets and nets with dynamics stay monolithic, the default. +- Add another option only where the page discusses it, such as `dt: 0.5` on Queue, or clock rates and `conflicts: "nondet"` on Fork under clocks. +- Every option an example opens with applies to its net, so the panel shows it and `compile` raises no `option-not-applicable` warning. `examples.test.ts` checks it. + +[compiler/README.md](../compiler/README.md#options) lists the options and the nets each applies to. + +## Adding an example + +1. Copy a folder from the same rung and rename it to the new id. +2. Edit `meta.ts`. Pick an `order` between the examples it builds on and the ones that build on it. +3. Replace `net.pn.yaml`. Write it in the block format the other examples use, one key per line and code as `|` blocks. +4. Rewrite `page.mdx`. Put a figure it alone uses in its `components/` folder. +5. Add the id to the expected outcomes in `examples.test.ts`. +6. Run `pnpm test` once. If the example compiles, this writes its Python to `__snapshots__//net.py`; read it. +7. Open `#` in `pnpm dev` or the build, and run `pnpm screenshot`. + +## Page structure + +````mdx +import { CoinDraws } from "./components/coin-draws"; + +The problem the example shows, in the first two sentences. Then what the +compiler writes for it, quoting the few Python lines that matter: + +```python +return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) +``` + +

+ +
+ +### Options + +- **Marking** on Int keeps the places Int and ... + +### Open questions + + + The question, and why it matters for this net. + +```` + +- The documentation view writes the net's name and the heading from `meta.ts`; the page starts with its first paragraph. +- The documentation view passes `Figure` and `OpenQuestion` to every page as MDX components, from `playground/docs/page-components.ts`. A page uses them without importing them, and imports only its own `./components/`. It also renders fenced code through `CodeBlock`, which wraps a long line under a hanging indent, so quote a line of Python in a fenced block when it is longer than about 25 characters: inline code does not wrap. +- Open with the problem, not with the net or the options. Name the IR items and the Python names the reader will see. Say once, on the page that needs it, what a later page builds on. +- **Options** lists only the options that change this net's output, never one the panel greys out, and not the shape the example opens with. +- Only the first page, Cycle, has a **Hover** section: one bullet on what pointing at a line or a node does. +- An open question is about the semantics of the compilation: what a net means as modules, or what Zeroth can express. Tooling, executors, numerics and plans belong elsewhere. Each question sits on the one page where it matters most. +- `owner` is `"HASH"`, `"Zeroth"` or `"HASH and Zeroth"`: who can settle the question. Keep it in step with the owner in [compiler/mapping.md](../compiler/mapping.md) where the question is listed there. +- A refused example says why under `### Refused here`, and what the compiler would write with a code parser. +- Maths is written as `$...$` or `$$...$$` and rendered to SVG at build time. Keep it short. +- A figure helps where a picture explains the mapping. Put the drawing in the example's `components/` folder, and wrap it in `
` on the page. `Figure` draws the frame and the caption; the frame's CSS is in `playground/docs/figures/figure.css`. + +## Figures + +Every React component of an example sits in its `components/` folder, with its CSS beside it, and imports nothing from `playground/`. A drawing is small, minimal and correct about the step it shows. + +- Text keeps a fixed size whatever the panel's width: the docs panel is about 200 px wide in a 1000 px window and 300 px in a 1440 px one. Lay a drawing out in HTML and CSS, or in an SVG without a `viewBox`, placed in percentages or drawn at the width a `ResizeObserver` measures. Never scale text with a `viewBox`. +- `Figure` is a size container, so a drawing can change its layout with `@container` queries. +- Animate with CSS alone. Under `prefers-reduced-motion: reduce`, stop every animation and show one frame that still makes the point. +- Mark a state in text as well as in colour, such as "fires" or "waits". +- Check a figure headlessly at 1440×900 and 1000×800, at several animation times and with reduced motion, as [playground/README.md](../playground/README.md#checking-the-ui) describes. + +## Tests + +- `catalog.test.ts` checks that every folder holds a `meta.ts`, a `net.pn.yaml` and a `page.mdx`, and that the examples climb the ladder rung by rung, each at its own `order`. +- `playground/docs/example-pages.test.tsx` renders every page with the components the documentation view passes. MDX is not type-checked, so this test catches a component a page uses but neither imports nor is passed, and an `owner` outside the three values. +- `examples.test.ts` compiles each example under the options it opens with. It checks the outcome table (compiles, or the codes it is refused with), that no option is dropped, and each Python file against its snapshot in `__snapshots__//`, one folder per example that compiles. A change that alters the output updates the snapshots with `pnpm test -u` and lists the diff in its commit. diff --git a/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py b/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py new file mode 100644 index 00000000..c4c3d2d9 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py @@ -0,0 +1,52 @@ +from zrth import LRA, Bool, Real, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Population = Var(REAL) + +u_Birth = Var(REAL) # uniform draw for Birth, each step +u_Death = Var(REAL) # uniform draw for Death, each step + +fire_Birth = Var(BOOL) # Birth fires this step +fire_Death = Var(BOOL) # Death fires this step + + +class Transition_Birth(Module): + """Birth: nothing -> Population, at rate 2""" + + def init(self, u_Birth): + return False + + def next(self, fire_Birth, u_Birth): + return X(u_Birth) >= 0.1353352832366127 + + +class Transition_Death(Module): + """Death: Population -> nothing, at rate 1""" + + def init(self, Population, u_Death): + return False + + def next(self, fire_Death, Population, u_Death): + return (Population >= 1.0) & (X(u_Death) >= 0.36787944117144233) + + +class Place_Population(Module): + """Population: taken by Death, added by Birth""" + + def init(self, fire_Birth, fire_Death): + return 0.0 + + def next(self, Population, fire_Birth, fire_Death): + Population = ite(X(fire_Birth), Population + 1.0, Population) # Birth adds 1 + Population = ite(X(fire_Death), Population - 1.0, Population) # Death takes 1 + return Population + + +transition_Birth = Transition_Birth(theory=LRA, ctrl=(fire_Birth,), extl=(u_Birth,)) +transition_Death = Transition_Death(theory=LRA, ctrl=(fire_Death,), extl=(Population, u_Death)) +place_Population = Place_Population(theory=LRA, ctrl=(Population,), extl=(fire_Birth, fire_Death)) +net = compose(transition_Birth, transition_Death, place_Population) diff --git a/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py b/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py new file mode 100644 index 00000000..ad878f29 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py @@ -0,0 +1,114 @@ +from zrth import LRA, Bool, Real, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Waiting = Var(REAL) +FreeStaff = Var(REAL) +Serving = Var(REAL) +Served = Var(REAL) + +u_Arrive = Var(REAL) # uniform draw for Arrive, each step +u_BeginService = Var(REAL) # uniform draw for BeginService, each step +u_FinishService = Var(REAL) # uniform draw for FinishService, each step + +fire_Arrive = Var(BOOL) # Arrive fires this step +fire_BeginService = Var(BOOL) # BeginService fires this step +fire_FinishService = Var(BOOL) # FinishService fires this step + + +class Transition_Arrive(Module): + """Arrive: nothing -> Waiting, at rate 1.2""" + + def init(self, u_Arrive): + return False + + def next(self, fire_Arrive, u_Arrive): + return X(u_Arrive) >= 0.3011942119122021 + + +class Transition_BeginService(Module): + """BeginService: Waiting, FreeStaff -> Serving, at rate 0.4""" + + def init(self, Waiting, FreeStaff, u_BeginService): + return False + + def next(self, fire_BeginService, Waiting, FreeStaff, u_BeginService): + return (Waiting >= 1.0) & (FreeStaff >= 1.0) & (X(u_BeginService) >= 0.6703200460356393) + + +class Transition_FinishService(Module): + """FinishService: Serving -> Served, FreeStaff, at rate 0.5""" + + def init(self, Serving, u_FinishService): + return False + + def next(self, fire_FinishService, Serving, u_FinishService): + return (Serving >= 1.0) & (X(u_FinishService) >= 0.6065306597126334) + + +class Place_Waiting(Module): + """Waiting: taken by BeginService, added by Arrive""" + + def init(self, fire_Arrive, fire_BeginService): + return 3.0 + + def next(self, Waiting, fire_Arrive, fire_BeginService): + Waiting = ite(X(fire_Arrive), Waiting + 1.0, Waiting) # Arrive adds 1 + Waiting = ite(X(fire_BeginService), Waiting - 1.0, Waiting) # BeginService takes 1 + return Waiting + + +class Place_FreeStaff(Module): + """FreeStaff: taken by BeginService, added by FinishService""" + + def init(self, fire_BeginService, fire_FinishService): + return 2.0 + + def next(self, FreeStaff, fire_BeginService, fire_FinishService): + FreeStaff = ite(X(fire_BeginService), FreeStaff - 1.0, FreeStaff) # BeginService takes 1 + FreeStaff = ite(X(fire_FinishService), FreeStaff + 1.0, FreeStaff) # FinishService adds 1 + return FreeStaff + + +class Place_Serving(Module): + """Serving: taken by FinishService, added by BeginService""" + + def init(self, fire_BeginService, fire_FinishService): + return 0.0 + + def next(self, Serving, fire_BeginService, fire_FinishService): + Serving = ite(X(fire_BeginService), Serving + 1.0, Serving) # BeginService adds 1 + Serving = ite(X(fire_FinishService), Serving - 1.0, Serving) # FinishService takes 1 + return Serving + + +class Place_Served(Module): + """Served: added by FinishService""" + + def init(self, fire_FinishService): + return 0.0 + + def next(self, Served, fire_FinishService): + Served = ite(X(fire_FinishService), Served + 1.0, Served) # FinishService adds 1 + return Served + + +transition_Arrive = Transition_Arrive(theory=LRA, ctrl=(fire_Arrive,), extl=(u_Arrive,)) +transition_BeginService = Transition_BeginService(theory=LRA, ctrl=(fire_BeginService,), extl=(Waiting, FreeStaff, u_BeginService)) +transition_FinishService = Transition_FinishService(theory=LRA, ctrl=(fire_FinishService,), extl=(Serving, u_FinishService)) +place_Waiting = Place_Waiting(theory=LRA, ctrl=(Waiting,), extl=(fire_Arrive, fire_BeginService)) +place_FreeStaff = Place_FreeStaff(theory=LRA, ctrl=(FreeStaff,), extl=(fire_BeginService, fire_FinishService)) +place_Serving = Place_Serving(theory=LRA, ctrl=(Serving,), extl=(fire_BeginService, fire_FinishService)) +place_Served = Place_Served(theory=LRA, ctrl=(Served,), extl=(fire_FinishService,)) +net = compose( + transition_Arrive, + transition_BeginService, + transition_FinishService, + place_Waiting, + place_FreeStaff, + place_Serving, + place_Served, +) diff --git a/pocs/zeroth-playground/examples/__snapshots__/capacity/net.py b/pocs/zeroth-playground/examples/__snapshots__/capacity/net.py new file mode 100644 index 00000000..57fc3f37 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/capacity/net.py @@ -0,0 +1,111 @@ +from zrth import LIA, Bool, Int, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +LeftSource = Var(INT) +RightSource = Var(INT) +Buffer = Var(INT) +Sink = Var(INT) + +fire_PutLeft = Var(BOOL) # PutLeft fires this step +fire_PutRight = Var(BOOL) # PutRight fires this step +fire_Take = Var(BOOL) # Take fires this step + + +class Transition_PutLeft(Module): + """PutLeft: LeftSource -> Buffer""" + + def init(self, LeftSource, Buffer): + return False + + def next(self, fire_PutLeft, LeftSource, Buffer): + return (LeftSource >= 1) & (Buffer + 1 <= 2) + + +class Transition_PutRight(Module): + """PutRight: RightSource -> Buffer""" + + def init(self, RightSource, Buffer, fire_PutLeft): + return False + + def next(self, fire_PutRight, RightSource, Buffer, fire_PutLeft): + fill_Buffer = Buffer + fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) # PutLeft added 1 + return (RightSource >= 1) & (fill_Buffer + 1 <= 2) + + +class Transition_Take(Module): + """Take: Buffer -> Sink""" + + def init(self, Buffer): + return False + + def next(self, fire_Take, Buffer): + return Buffer >= 1 + + +class Place_LeftSource(Module): + """LeftSource: taken by PutLeft""" + + def init(self, fire_PutLeft): + return 5 + + def next(self, LeftSource, fire_PutLeft): + LeftSource = ite(X(fire_PutLeft), LeftSource - 1, LeftSource) # PutLeft takes 1 + return LeftSource + + +class Place_RightSource(Module): + """RightSource: taken by PutRight""" + + def init(self, fire_PutRight): + return 5 + + def next(self, RightSource, fire_PutRight): + RightSource = ite(X(fire_PutRight), RightSource - 1, RightSource) # PutRight takes 1 + return RightSource + + +class Place_Buffer(Module): + """Buffer: taken by Take, added by PutLeft, PutRight""" + + def init(self, fire_PutLeft, fire_PutRight, fire_Take): + return 0 + + def next(self, Buffer, fire_PutLeft, fire_PutRight, fire_Take): + Buffer = ite(X(fire_PutLeft), Buffer + 1, Buffer) # PutLeft adds 1 + Buffer = ite(X(fire_PutRight), Buffer + 1, Buffer) # PutRight adds 1 + Buffer = ite(X(fire_Take), Buffer - 1, Buffer) # Take takes 1 + return Buffer + + +class Place_Sink(Module): + """Sink: added by Take""" + + def init(self, fire_Take): + return 0 + + def next(self, Sink, fire_Take): + Sink = ite(X(fire_Take), Sink + 1, Sink) # Take adds 1 + return Sink + + +transition_PutLeft = Transition_PutLeft(theory=LIA, ctrl=(fire_PutLeft,), extl=(LeftSource, Buffer)) +transition_PutRight = Transition_PutRight(theory=LIA, ctrl=(fire_PutRight,), extl=(RightSource, Buffer, fire_PutLeft)) +transition_Take = Transition_Take(theory=LIA, ctrl=(fire_Take,), extl=(Buffer,)) +place_LeftSource = Place_LeftSource(theory=LIA, ctrl=(LeftSource,), extl=(fire_PutLeft,)) +place_RightSource = Place_RightSource(theory=LIA, ctrl=(RightSource,), extl=(fire_PutRight,)) +place_Buffer = Place_Buffer(theory=LIA, ctrl=(Buffer,), extl=(fire_PutLeft, fire_PutRight, fire_Take)) +place_Sink = Place_Sink(theory=LIA, ctrl=(Sink,), extl=(fire_Take,)) +net = compose( + transition_PutLeft, + transition_PutRight, + transition_Take, + place_LeftSource, + place_RightSource, + place_Buffer, + place_Sink, +) diff --git a/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py b/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py new file mode 100644 index 00000000..89b096d0 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py @@ -0,0 +1,83 @@ +from zrth import LIA, Bool, Int, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +Pool = Var(INT) +Left = Var(INT) +Right = Var(INT) + +fire_TakeLeft = Var(BOOL) # TakeLeft fires this step +fire_TakeRight = Var(BOOL) # TakeRight fires this step + + +class Transition_TakeLeft(Module): + """TakeLeft: 2 Pool -> Left""" + + def init(self, Pool): + return False + + def next(self, fire_TakeLeft, Pool): + return Pool >= 2 + + +class Transition_TakeRight(Module): + """TakeRight: Pool -> Right""" + + def init(self, Pool, fire_TakeLeft): + return False + + def next(self, fire_TakeRight, Pool, fire_TakeLeft): + avail_Pool = Pool + avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 2, avail_Pool) # TakeLeft took 2 + return avail_Pool >= 1 + + +class Place_Pool(Module): + """Pool: taken by TakeLeft, TakeRight""" + + def init(self, fire_TakeLeft, fire_TakeRight): + return 3 + + def next(self, Pool, fire_TakeLeft, fire_TakeRight): + Pool = ite(X(fire_TakeLeft), Pool - 2, Pool) # TakeLeft takes 2 + Pool = ite(X(fire_TakeRight), Pool - 1, Pool) # TakeRight takes 1 + return Pool + + +class Place_Left(Module): + """Left: added by TakeLeft""" + + def init(self, fire_TakeLeft): + return 0 + + def next(self, Left, fire_TakeLeft): + Left = ite(X(fire_TakeLeft), Left + 1, Left) # TakeLeft adds 1 + return Left + + +class Place_Right(Module): + """Right: added by TakeRight""" + + def init(self, fire_TakeRight): + return 0 + + def next(self, Right, fire_TakeRight): + Right = ite(X(fire_TakeRight), Right + 1, Right) # TakeRight adds 1 + return Right + + +transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool,)) +transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft)) +place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight)) +place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft,)) +place_Right = Place_Right(theory=LIA, ctrl=(Right,), extl=(fire_TakeRight,)) +net = compose( + transition_TakeLeft, + transition_TakeRight, + place_Pool, + place_Left, + place_Right, +) diff --git a/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py b/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py new file mode 100644 index 00000000..84f91f6e --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py @@ -0,0 +1,63 @@ +from zrth import LIA, Bool, Int, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +A = Var(INT) +B = Var(INT) + +fire_Go = Var(BOOL) # Go fires this step +fire_Back = Var(BOOL) # Back fires this step + + +class Transition_Go(Module): + """Go: A -> B""" + + def init(self, A): + return False + + def next(self, fire_Go, A): + return A >= 1 + + +class Transition_Back(Module): + """Back: B -> A""" + + def init(self, B): + return False + + def next(self, fire_Back, B): + return B >= 1 + + +class Place_A(Module): + """A: taken by Go, added by Back""" + + def init(self, fire_Go, fire_Back): + return 1 + + def next(self, A, fire_Go, fire_Back): + A = ite(X(fire_Go), A - 1, A) # Go takes 1 + A = ite(X(fire_Back), A + 1, A) # Back adds 1 + return A + + +class Place_B(Module): + """B: taken by Back, added by Go""" + + def init(self, fire_Go, fire_Back): + return 0 + + def next(self, B, fire_Go, fire_Back): + B = ite(X(fire_Go), B + 1, B) # Go adds 1 + B = ite(X(fire_Back), B - 1, B) # Back takes 1 + return B + + +transition_Go = Transition_Go(theory=LIA, ctrl=(fire_Go,), extl=(A,)) +transition_Back = Transition_Back(theory=LIA, ctrl=(fire_Back,), extl=(B,)) +place_A = Place_A(theory=LIA, ctrl=(A,), extl=(fire_Go, fire_Back)) +place_B = Place_B(theory=LIA, ctrl=(B,), extl=(fire_Go, fire_Back)) +net = compose(transition_Go, transition_Back, place_A, place_B) diff --git a/pocs/zeroth-playground/examples/__snapshots__/fork-clocked/net.py b/pocs/zeroth-playground/examples/__snapshots__/fork-clocked/net.py new file mode 100644 index 00000000..1d0ac692 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/fork-clocked/net.py @@ -0,0 +1,115 @@ +from zrth import SPN, Bool, Clock, Event, Nat, Var +from zrth import Module as compose +from zrth.sugar import Module, d, ite, fired, exp + +t = Var(Clock()) # the time reference + +Pool = Var(Nat()) +Left = Var(Nat()) +Right = Var(Nat()) + +clk_TakeLeft = Var(Clock()) # time left until TakeLeft fires +clk_TakeRight = Var(Clock()) # time left until TakeRight fires +clk_Return = Var(Clock()) # time left until Return fires + +ev_TakeLeft = Var(Event()) # toggles when TakeLeft fires +ev_TakeRight = Var(Event()) # toggles when TakeRight fires +ev_Return = Var(Event()) # toggles when Return fires + +pick_TakeLeft = Var(Bool([1, 1])) # the environment lets TakeLeft fire when its clock expires +pick_TakeRight = Var(Bool([1, 1])) # the environment lets TakeRight fire when its clock expires + + +class Transition_TakeLeft(Module): + """TakeLeft: Pool -> Left, at rate 1""" + + def init(self, Pool, pick_TakeLeft, t): + return exp(1.0), False + + def next(self, clk_TakeLeft, ev_TakeLeft, Pool, pick_TakeLeft, t): + fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft + return ite(fires_TakeLeft, exp(1.0), clk_TakeLeft), ite(fires_TakeLeft, ~ev_TakeLeft, None) + + def flow(self, clk_TakeLeft, ev_TakeLeft, Pool, pick_TakeLeft, t): + return ite(clk_TakeLeft >= 0, ite(Pool != 0, -1 * d(t), 0 * d(t)), None), 0 + + +class Transition_TakeRight(Module): + """TakeRight: Pool -> Right, at rate 2""" + + def init(self, Pool, pick_TakeRight, t): + return exp(2.0), False + + def next(self, clk_TakeRight, ev_TakeRight, Pool, pick_TakeRight, t): + fires_TakeRight = (clk_TakeRight == 0) & (Pool != 0) & pick_TakeRight + return ite(fires_TakeRight, exp(2.0), clk_TakeRight), ite(fires_TakeRight, ~ev_TakeRight, None) + + def flow(self, clk_TakeRight, ev_TakeRight, Pool, pick_TakeRight, t): + return ite(clk_TakeRight >= 0, ite(Pool != 0, -1 * d(t), 0 * d(t)), None), 0 + + +class Transition_Return(Module): + """Return: Left -> Pool, at rate 0.5""" + + def init(self, Left, t): + return exp(0.5), False + + def next(self, clk_Return, ev_Return, Left, t): + fires_Return = (clk_Return == 0) & (Left != 0) + return ite(fires_Return, exp(0.5), clk_Return), ite(fires_Return, ~ev_Return, None) + + def flow(self, clk_Return, ev_Return, Left, t): + return ite(clk_Return >= 0, ite(Left != 0, -1 * d(t), 0 * d(t)), None), 0 + + +class Place_Pool(Module): + """Pool: added by Return, taken by TakeLeft, TakeRight""" + + def init(self, ev_Return, ev_TakeLeft, ev_TakeRight): + return 3 + + def next(self, Pool, ev_Return, ev_TakeLeft, ev_TakeRight): + fired_Return = fired(ev_Return) + fired_TakeLeft = fired(ev_TakeLeft) + fired_TakeRight = fired(ev_TakeRight) + return ite(fired_Return & ~fired_TakeLeft & ~fired_TakeRight, Pool + 1, ite(fired_TakeLeft & ~fired_Return & ~fired_TakeRight & (Pool != 0), Pool - 1, ite(fired_TakeRight & ~fired_Return & ~fired_TakeLeft & (Pool != 0), Pool - 1, Pool))) + + +class Place_Left(Module): + """Left: added by TakeLeft, taken by Return""" + + def init(self, ev_TakeLeft, ev_Return): + return 0 + + def next(self, Left, ev_TakeLeft, ev_Return): + fired_TakeLeft = fired(ev_TakeLeft) + fired_Return = fired(ev_Return) + return ite(fired_TakeLeft & ~fired_Return, Left + 1, ite(fired_Return & ~fired_TakeLeft & (Left != 0), Left - 1, Left)) + + +class Place_Right(Module): + """Right: added by TakeRight""" + + def init(self, ev_TakeRight): + return 0 + + def next(self, Right, ev_TakeRight): + fired_TakeRight = fired(ev_TakeRight) + return ite(fired_TakeRight, Right + 1, Right) + + +transition_TakeLeft = Transition_TakeLeft(theory=SPN, ctrl=(clk_TakeLeft, ev_TakeLeft), extl=(Pool, pick_TakeLeft, t)) +transition_TakeRight = Transition_TakeRight(theory=SPN, ctrl=(clk_TakeRight, ev_TakeRight), extl=(Pool, pick_TakeRight, t)) +transition_Return = Transition_Return(theory=SPN, ctrl=(clk_Return, ev_Return), extl=(Left, t)) +place_Pool = Place_Pool(theory=SPN, ctrl=(Pool,), extl=(ev_Return, ev_TakeLeft, ev_TakeRight)) +place_Left = Place_Left(theory=SPN, ctrl=(Left,), extl=(ev_TakeLeft, ev_Return)) +place_Right = Place_Right(theory=SPN, ctrl=(Right,), extl=(ev_TakeRight,)) +net = compose( + transition_TakeLeft, + transition_TakeRight, + transition_Return, + place_Pool, + place_Left, + place_Right, + hide={clk_TakeLeft, clk_TakeRight, clk_Return}, +) diff --git a/pocs/zeroth-playground/examples/__snapshots__/fork/net.py b/pocs/zeroth-playground/examples/__snapshots__/fork/net.py new file mode 100644 index 00000000..38d06f52 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/fork/net.py @@ -0,0 +1,98 @@ +from zrth import LIA, Bool, Int, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +Pool = Var(INT) +Left = Var(INT) +Right = Var(INT) + +fire_TakeLeft = Var(BOOL) # TakeLeft fires this step +fire_TakeRight = Var(BOOL) # TakeRight fires this step +fire_Return = Var(BOOL) # Return fires this step + + +class Transition_TakeLeft(Module): + """TakeLeft: Pool -> Left""" + + def init(self, Pool): + return False + + def next(self, fire_TakeLeft, Pool): + return Pool >= 1 + + +class Transition_TakeRight(Module): + """TakeRight: Pool -> Right""" + + def init(self, Pool, fire_TakeLeft): + return False + + def next(self, fire_TakeRight, Pool, fire_TakeLeft): + avail_Pool = Pool + avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 1, avail_Pool) # TakeLeft took 1 + return avail_Pool >= 1 + + +class Transition_Return(Module): + """Return: Left -> Pool""" + + def init(self, Left): + return False + + def next(self, fire_Return, Left): + return Left >= 1 + + +class Place_Pool(Module): + """Pool: taken by TakeLeft, TakeRight, added by Return""" + + def init(self, fire_TakeLeft, fire_TakeRight, fire_Return): + return 3 + + def next(self, Pool, fire_TakeLeft, fire_TakeRight, fire_Return): + Pool = ite(X(fire_TakeLeft), Pool - 1, Pool) # TakeLeft takes 1 + Pool = ite(X(fire_TakeRight), Pool - 1, Pool) # TakeRight takes 1 + Pool = ite(X(fire_Return), Pool + 1, Pool) # Return adds 1 + return Pool + + +class Place_Left(Module): + """Left: taken by Return, added by TakeLeft""" + + def init(self, fire_TakeLeft, fire_Return): + return 0 + + def next(self, Left, fire_TakeLeft, fire_Return): + Left = ite(X(fire_TakeLeft), Left + 1, Left) # TakeLeft adds 1 + Left = ite(X(fire_Return), Left - 1, Left) # Return takes 1 + return Left + + +class Place_Right(Module): + """Right: added by TakeRight""" + + def init(self, fire_TakeRight): + return 0 + + def next(self, Right, fire_TakeRight): + Right = ite(X(fire_TakeRight), Right + 1, Right) # TakeRight adds 1 + return Right + + +transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool,)) +transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft)) +transition_Return = Transition_Return(theory=LIA, ctrl=(fire_Return,), extl=(Left,)) +place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight, fire_Return)) +place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft, fire_Return)) +place_Right = Place_Right(theory=LIA, ctrl=(Right,), extl=(fire_TakeRight,)) +net = compose( + transition_TakeLeft, + transition_TakeRight, + transition_Return, + place_Pool, + place_Left, + place_Right, +) diff --git a/pocs/zeroth-playground/examples/__snapshots__/queue/net.py b/pocs/zeroth-playground/examples/__snapshots__/queue/net.py new file mode 100644 index 00000000..1c3cd160 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/queue/net.py @@ -0,0 +1,65 @@ +from zrth import LRA, Bool, Real, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Waiting = Var(REAL) +Served = Var(REAL) + +u_Arrive = Var(REAL) # uniform draw for Arrive, each step +u_Serve = Var(REAL) # uniform draw for Serve, each step + +fire_Arrive = Var(BOOL) # Arrive fires this step +fire_Serve = Var(BOOL) # Serve fires this step + + +class Transition_Arrive(Module): + """Arrive: nothing -> Waiting, at rate 2.5""" + + def init(self, Waiting, u_Arrive): + return False + + def next(self, fire_Arrive, Waiting, u_Arrive): + return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) + + +class Transition_Serve(Module): + """Serve: Waiting -> Served, at rate 3""" + + def init(self, Waiting, u_Serve): + return False + + def next(self, fire_Serve, Waiting, u_Serve): + return (Waiting >= 1.0) & (X(u_Serve) >= 0.22313016014842982) + + +class Place_Waiting(Module): + """Waiting: taken by Serve, added by Arrive""" + + def init(self, fire_Arrive, fire_Serve): + return 0.0 + + def next(self, Waiting, fire_Arrive, fire_Serve): + Waiting = ite(X(fire_Arrive), Waiting + 1.0, Waiting) # Arrive adds 1 + Waiting = ite(X(fire_Serve), Waiting - 1.0, Waiting) # Serve takes 1 + return Waiting + + +class Place_Served(Module): + """Served: added by Serve""" + + def init(self, fire_Serve): + return 0.0 + + def next(self, Served, fire_Serve): + Served = ite(X(fire_Serve), Served + 1.0, Served) # Serve adds 1 + return Served + + +transition_Arrive = Transition_Arrive(theory=LRA, ctrl=(fire_Arrive,), extl=(Waiting, u_Arrive)) +transition_Serve = Transition_Serve(theory=LRA, ctrl=(fire_Serve,), extl=(Waiting, u_Serve)) +place_Waiting = Place_Waiting(theory=LRA, ctrl=(Waiting,), extl=(fire_Arrive, fire_Serve)) +place_Served = Place_Served(theory=LRA, ctrl=(Served,), extl=(fire_Serve,)) +net = compose(transition_Arrive, transition_Serve, place_Waiting, place_Served) diff --git a/pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py b/pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py new file mode 100644 index 00000000..39124038 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py @@ -0,0 +1,86 @@ +from zrth import LRA, Bool, Real, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +REAL = Real([1, 1]) +BOOL = Bool([1, 1]) + +Susceptible = Var(REAL) +Infected = Var(REAL) +Recovered = Var(REAL) + +u_Infection = Var(REAL) # uniform draw for Infection, each step +u_Recovery = Var(REAL) # uniform draw for Recovery, each step + +fire_Infection = Var(BOOL) # Infection fires this step +fire_Recovery = Var(BOOL) # Recovery fires this step + + +class Transition_Infection(Module): + """Infection: Susceptible, Infected -> 2 Infected, at rate 3""" + + def init(self, Susceptible, Infected, u_Infection): + return False + + def next(self, fire_Infection, Susceptible, Infected, u_Infection): + return (Susceptible >= 1.0) & (Infected >= 1.0) & (X(u_Infection) >= 0.049787068367863944) + + +class Transition_Recovery(Module): + """Recovery: Infected -> Recovered, at rate 1""" + + def init(self, Infected, fire_Infection, u_Recovery): + return False + + def next(self, fire_Recovery, Infected, fire_Infection, u_Recovery): + avail_Infected = Infected + avail_Infected = ite(X(fire_Infection), avail_Infected - 1.0, avail_Infected) # Infection took 1 + return (avail_Infected >= 1.0) & (X(u_Recovery) >= 0.36787944117144233) + + +class Place_Susceptible(Module): + """Susceptible: taken by Infection""" + + def init(self, fire_Infection): + return 9.0 + + def next(self, Susceptible, fire_Infection): + Susceptible = ite(X(fire_Infection), Susceptible - 1.0, Susceptible) # Infection takes 1 + return Susceptible + + +class Place_Infected(Module): + """Infected: taken by Recovery, added by Infection""" + + def init(self, fire_Infection, fire_Recovery): + return 1.0 + + def next(self, Infected, fire_Infection, fire_Recovery): + Infected = ite(X(fire_Infection), Infected + 1.0, Infected) # Infection adds 1 + Infected = ite(X(fire_Recovery), Infected - 1.0, Infected) # Recovery takes 1 + return Infected + + +class Place_Recovered(Module): + """Recovered: added by Recovery""" + + def init(self, fire_Recovery): + return 0.0 + + def next(self, Recovered, fire_Recovery): + Recovered = ite(X(fire_Recovery), Recovered + 1.0, Recovered) # Recovery adds 1 + return Recovered + + +transition_Infection = Transition_Infection(theory=LRA, ctrl=(fire_Infection,), extl=(Susceptible, Infected, u_Infection)) +transition_Recovery = Transition_Recovery(theory=LRA, ctrl=(fire_Recovery,), extl=(Infected, fire_Infection, u_Recovery)) +place_Susceptible = Place_Susceptible(theory=LRA, ctrl=(Susceptible,), extl=(fire_Infection,)) +place_Infected = Place_Infected(theory=LRA, ctrl=(Infected,), extl=(fire_Infection, fire_Recovery)) +place_Recovered = Place_Recovered(theory=LRA, ctrl=(Recovered,), extl=(fire_Recovery,)) +net = compose( + transition_Infection, + transition_Recovery, + place_Susceptible, + place_Infected, + place_Recovered, +) diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.css b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.css new file mode 100644 index 00000000..fd0821be --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.css @@ -0,0 +1,187 @@ +/* -- Birth's coin against its clock: a Δt slider, the survival plot, a draw -- */ + +.explorer { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.75rem 0.8rem 0.8rem; + border: 1px solid var(--sw-hair-2); + border-radius: 2px; + background: var(--sw-paper-2); +} + +.explorer__field { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.explorer__field-label { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + color: var(--sw-ink); + font-size: 0.8rem; +} + +.explorer__field-value { + color: var(--sw-accent); + font-family: var(--sw-mono); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +.explorer__range { + width: 100%; + margin: 0; + accent-color: var(--sw-accent); +} + +.explorer__range:focus-visible, +.explorer__button:focus-visible { + outline: 2px solid var(--sw-accent); + outline-offset: 2px; +} + +/* The verdict sits beside the button, or under it when the panel is narrow. */ +.explorer__draw { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem 0.6rem; +} + +.explorer__button { + flex: none; + padding: 0.3rem 0.7rem; + border: 1px solid var(--sw-accent); + border-radius: 2px; + background: var(--sw-accent); + color: var(--sw-paper-2); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: background-color 120ms var(--sw-ease), border-color 120ms var(--sw-ease); +} + +.explorer__button:hover { + border-color: var(--sw-accent-deep); + background: var(--sw-accent-deep); +} + +.explorer__verdict { + flex: 1 1 13rem; + min-height: 2.9em; + padding-left: 0.55rem; + border-left: 2px solid var(--sw-hair); + color: var(--sw-ink-2); + font-size: 0.75rem; + line-height: 1.45; +} + +.explorer__verdict--fires { + border-left-color: var(--sw-accent); + color: var(--sw-ink); +} + +.explorer__verdict--waits { + border-left-color: var(--sw-ink-2); + color: var(--sw-ink); +} + +.explorer__rate { + padding-top: 0.45rem; + border-top: 1px solid var(--sw-hair-2); + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.72rem; +} + +.explorer__value { + color: var(--sw-ink); + font-weight: 600; +} + +/* The plot draws at one unit per pixel, so its text sizes are pixels on screen. */ +.survival-plot svg { + display: block; + overflow: visible; +} + +.survival-plot__text, +.survival-plot__draw-label { + font-family: var(--sw-mono); + font-size: 11px; +} + +.survival-plot__text--muted { + fill: var(--sw-ink-2); +} + +.survival-plot__text--accent { + fill: var(--sw-accent); +} + +.survival-plot__band { + fill: var(--sw-accent-wash); +} + +.survival-plot__mark { + stroke: var(--sw-accent); + stroke-width: 3; +} + +.survival-plot__axis { + stroke: var(--sw-ink-2); + stroke-width: 1.25; +} + +.survival-plot__guide { + stroke: var(--sw-accent); + stroke-width: 1; + stroke-dasharray: 3 3; +} + +.survival-plot__curve { + fill: none; + stroke: var(--sw-ink); + stroke-width: 1.5; + stroke-linejoin: round; +} + +.survival-plot__draw-line { + stroke-width: 1.25; +} + +.survival-plot__draw-label { + font-weight: 600; +} + +.survival-plot__draw--fires .survival-plot__draw-line { + stroke: var(--sw-accent); +} + +.survival-plot__draw--fires .survival-plot__draw-dot, +.survival-plot__draw--fires .survival-plot__draw-label { + fill: var(--sw-accent); +} + +.survival-plot__draw--waits .survival-plot__draw-line { + stroke: var(--sw-ink-2); + stroke-dasharray: 2 2; +} + +.survival-plot__draw--waits .survival-plot__draw-dot, +.survival-plot__draw--waits .survival-plot__draw-label { + fill: var(--sw-ink-2); +} + +@media (prefers-reduced-motion: reduce) { + .explorer__button { + transition: none; + } +} diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx new file mode 100644 index 00000000..85ea3601 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; + +import { delayFromDraw, effectiveRate, fireModifier, firesAt, threshold } from "./coin-clock-explorer/exponential"; +import { fixed } from "./coin-clock-explorer/format"; +import { modifierClass } from "./coin-clock-explorer/modifier-class"; +import { RangeField } from "./coin-clock-explorer/range-field"; +import { SurvivalPlot } from "./coin-clock-explorer/survival-plot"; + +import "./coin-clock-explorer.css"; + +/** Birth's rate. */ +const LAMBDA = 2; + +/** A uniform draw in (0, 1], so that its delay -ln(u)/λ is always finite. */ +function uniformDraw(): number { + return 1 - Math.random(); +} + +/** The parts of an equation joined by no-break spaces, so that a line never breaks inside it. */ +function unbroken(...parts: string[]): string { + return parts.join(" "); +} + +/** What one draw decides, read by the coin and by the clock it stands for. */ +function verdictOf(dt: number, draw: number | undefined): string { + if (draw === undefined) { + return "Press Draw to toss Birth's coin for one step."; + } + const theta = threshold(LAMBDA, dt); + const u = fixed(draw, 3); + const th = fixed(theta, 3); + const tau = fixed(delayFromDraw(LAMBDA, draw), 2); + return firesAt(draw, theta) + ? `${unbroken("u", "=", u, "≥", "θ", "=", th)}: fires. Its clock would expire at ${unbroken("τ", "=", tau, "≤", "Δt")}.` + : `${unbroken("u", "=", u, "<", "θ", "=", th)}: waits. Its clock would expire at ${unbroken("τ", "=", tau, ">", "Δt")}.`; +} + +/** + * Birth's coin against its clock: one draw read both ways on the survival + * curve, and the rate the coin reaches when it fires at most once per step. + */ +export const CoinClockExplorer: React.FC = () => { + const [dt, setDt] = useState(1); + const [draw, setDraw] = useState(undefined); + const theta = threshold(LAMBDA, dt); + + return ( +
+ + +
+ + {verdictOf(dt, draw)} +
+
+ Coin rate p/Δt = {fixed(effectiveRate(LAMBDA, dt), 2)}, below λ = {LAMBDA} +
+
+ ); +}; diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.test.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.test.ts new file mode 100644 index 00000000..7548e331 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import { + delayFromDraw, + effectiveRate, + fireModifier, + fireProbability, + firesAt, + survival, + threshold, +} from "./exponential"; +import { fixed } from "./format"; + +const RATES = [0.2, 0.5, 1, 2, 2.5, 4]; +const STEPS = [0.05, 0.25, 0.5, 1, 2]; + +describe("survival", () => { + it("starts at one and decays as e^(-λt)", () => { + // GIVEN rates 1 and 2, at times 0, 0.5 and 1 + // THEN nothing has expired at 0, and only λt matters + expect(survival(2, 0)).toBe(1); + expect(survival(1, 1)).toBeCloseTo(Math.exp(-1), 12); + expect(survival(2, 0.5)).toBeCloseTo(survival(1, 1), 12); + }); +}); + +describe("threshold and fire probability", () => { + it("read the queue's Arrive at rate 2.5 and dt 0.5 as the compiler does", () => { + // GIVEN Arrive's rate and the queue's time step + // THEN the threshold is the one in the Python, and the chance is its complement + expect(threshold(2.5, 0.5)).toBeCloseTo(0.2865047968601901, 15); + expect(fireProbability(2.5, 0.5)).toBeCloseTo(0.7134952031398099, 15); + }); + + it("split the unit interval between waiting and firing", () => { + // GIVEN every rate and step + for (const lambda of RATES) { + for (const dt of STEPS) { + // THEN θ and p add up to one + expect(threshold(lambda, dt) + fireProbability(lambda, dt)).toBeCloseTo(1, 14); + } + } + }); +}); + +describe("delayFromDraw", () => { + it("inverts the survival: u = S(τ)", () => { + // GIVEN draws across (0, 1] + for (const lambda of RATES) { + for (const u of [0.01, 0.2, 0.5, 0.9, 1]) { + // WHEN a draw is read as a delay + const tau = delayFromDraw(lambda, u); + // THEN the survival at that delay is the draw + expect(survival(lambda, tau)).toBeCloseTo(u, 12); + } + } + }); + + it("averages to the mean delay 1/λ over the draws", () => { + // GIVEN draws spread evenly over (0, 1] + const n = 100_000; + // WHEN each is read as a delay at rate 2 + let sum = 0; + for (let i = 0; i < n; i++) { + sum += delayFromDraw(2, (i + 0.5) / n); + } + // THEN the delays average to 1/2 + expect(sum / n).toBeCloseTo(0.5, 3); + }); +}); + +describe("coin and clock", () => { + it("decide the same for every draw: u ≥ θ exactly when τ ≤ dt", () => { + // GIVEN every rate, step and a fine grid of draws + const draws = Array.from({ length: 2000 }, (_, i) => (i + 1) / 2000); + // WHEN each draw is read both as a coin and as a clock + const disagreements = RATES.flatMap((lambda) => + STEPS.flatMap((dt) => + draws + .filter((u) => (delayFromDraw(lambda, u) <= dt) !== firesAt(u, threshold(lambda, dt))) + .map((u) => ({ lambda, dt, u })), + ), + ); + // THEN the clock expires within the step exactly when the coin fires + expect(disagreements).toEqual([]); + }); +}); + +describe("effectiveRate", () => { + it("stays below λ for every step", () => { + // GIVEN every rate and step + for (const lambda of RATES) { + for (const dt of STEPS) { + // THEN a coin that fires at most once per step falls short of λ + expect(effectiveRate(lambda, dt)).toBeLessThan(lambda); + } + } + }); + + it("rises towards λ as the step shrinks", () => { + // GIVEN steps that shrink by orders of magnitude + // WHEN the coin's rate is read at each + const rates = [1, 0.5, 0.1, 0.01, 0.0001].map((dt) => effectiveRate(2, dt)); + // THEN it rises at every step and ends within 0.001 of λ = 2 + for (let i = 1; i < rates.length; i++) { + expect(rates[i]).toBeGreaterThan(rates[i - 1] ?? 0); + } + expect(rates.at(-1)).toBeCloseTo(2, 3); + }); + + it("prints below λ = 2 with two decimals over the Δt slider", () => { + // GIVEN every step the slider offers, 0.05 to 2 by 0.05 + for (let d = 1; d <= 40; d++) { + // WHEN the readout prints the coin's rate at Birth's λ = 2 + const printed = Number(fixed(effectiveRate(2, d / 20), 2)); + // THEN it reads below λ + expect(printed).toBeLessThan(2); + } + }); +}); + +describe("firesAt", () => { + it("fires on the threshold and above, waits below", () => { + // GIVEN a threshold of 0.5, and draws on it, above it and below it + // THEN the test is u ≥ θ + expect(firesAt(0.5, 0.5)).toBe(true); + expect(firesAt(0.6, 0.5)).toBe(true); + expect(firesAt(0.4, 0.5)).toBe(false); + }); +}); + +describe("fireModifier", () => { + it("names the reading of a draw, and nothing without one", () => { + // GIVEN a threshold of 0.5, and no draw, a draw above it and one below it + // THEN a draw reads as fires or waits, and no draw as nothing + expect(fireModifier(undefined, 0.5)).toBeUndefined(); + expect(fireModifier(0.7, 0.5)).toBe("fires"); + expect(fireModifier(0.2, 0.5)).toBe("waits"); + }); +}); diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.ts new file mode 100644 index 00000000..12b72f9b --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.ts @@ -0,0 +1,42 @@ +/** + * The exponential delay behind both ways of compiling a rate: the survival a + * clock reads, the threshold a coin tests, and the identities between them. + */ + +/** The probability that a delay of rate `lambda` has not expired by time `t`: e^(-λt). */ +export function survival(lambda: number, t: number): number { + return Math.exp(-lambda * t); +} + +/** The coin's threshold for one step of `dt`, computed at compile time: θ = e^(-λ·dt). */ +export function threshold(lambda: number, dt: number): number { + return survival(lambda, dt); +} + +/** The chance a coin fires in one step of `dt`: p = 1 - e^(-λ·dt). */ +export function fireProbability(lambda: number, dt: number): number { + return -Math.expm1(-lambda * dt); +} + +/** The clock delay one uniform draw `u` in (0, 1] stands for: τ = -ln(u)/λ, so that u = S(τ). */ +export function delayFromDraw(lambda: number, u: number): number { + return -Math.log(u) / lambda; +} + +/** Whether a draw `u` reaches the threshold `theta`: the coin's test, u ≥ θ. */ +export function firesAt(u: number, theta: number): boolean { + return u >= theta; +} + +/** The class modifier for a draw against `theta`, or undefined when there is no draw. */ +export function fireModifier(u: number | undefined, theta: number): "fires" | "waits" | undefined { + if (u === undefined) { + return undefined; + } + return firesAt(u, theta) ? "fires" : "waits"; +} + +/** The rate a coin achieves when it fires at most once per step: p/dt, always below λ. */ +export function effectiveRate(lambda: number, dt: number): number { + return fireProbability(lambda, dt) / dt; +} diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/format.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/format.ts new file mode 100644 index 00000000..3a790fec --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/format.ts @@ -0,0 +1,4 @@ +/** A number for a readout, with a fixed count of decimals and a true minus sign. */ +export function fixed(value: number, digits: number): string { + return Number.isFinite(value) ? value.toFixed(digits).replace("-", "−") : "∞"; +} diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.test.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.test.ts new file mode 100644 index 00000000..6a6da0c1 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { modifierClass } from "./modifier-class"; + +describe("modifierClass", () => { + it("adds the modifier when there is one", () => { + // GIVEN the block explorer__draw, with the modifier fires and with none + // THEN a modifier is appended to the block, and no modifier leaves the block alone + expect(modifierClass("explorer__draw", "fires")).toBe("explorer__draw explorer__draw--fires"); + expect(modifierClass("explorer__draw", undefined)).toBe("explorer__draw"); + }); +}); diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.ts new file mode 100644 index 00000000..6b207d81 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.ts @@ -0,0 +1,4 @@ +/** A BEM class with its modifier when there is one: "block block--modifier", or "block". */ +export function modifierClass(block: string, modifier: string | undefined): string { + return modifier === undefined ? block : `${block} ${block}--${modifier}`; +} diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.test.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.test.ts new file mode 100644 index 00000000..2f37dbc8 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { curvePath, niceCeil, xOf, yOf } from "./plot-frame"; + +import type { PlotFrame } from "./plot-frame"; + +const FRAME: PlotFrame = { width: 120, height: 120, left: 10, right: 10, top: 10, bottom: 10, horizon: 4 }; + +describe("niceCeil", () => { + it("rounds up to a round number", () => { + // GIVEN round numbers, numbers between them, and zero + // THEN a round number stays, and anything else goes up to the next one + expect(niceCeil(3)).toBe(3); + expect(niceCeil(3.1)).toBe(4); + expect(niceCeil(0.75)).toBe(0.8); + expect(niceCeil(12.5)).toBe(15); + expect(niceCeil(0)).toBe(1); + }); +}); + +describe("xOf and yOf", () => { + it("map time across and probability up, clamped to the frame", () => { + // GIVEN a frame from 10 to 110 both ways, with a horizon of 4 + // THEN t = 2 sits halfway across, t = 9 at the edge, and probability 1 at the top + expect(xOf(FRAME, 0)).toBe(10); + expect(xOf(FRAME, 2)).toBe(60); + expect(xOf(FRAME, 9)).toBe(110); + expect(yOf(FRAME, 1)).toBe(10); + expect(yOf(FRAME, 0)).toBe(110); + }); +}); + +describe("curvePath", () => { + it("starts at the first sample and stops at the horizon", () => { + // GIVEN a frame with a horizon of 4 + // WHEN a flat curve is drawn from 0 to 10 in four samples + const path = curvePath(FRAME, () => 1, 0, 10, 4); + // THEN it runs along the top up to the horizon, 4 + expect(path).toBe("M10,10L35,10L60,10L85,10L110,10"); + }); + + it("is empty when the span starts past the horizon", () => { + // GIVEN a frame that ends at 4 + // THEN a curve that starts at 5 draws nothing + expect(curvePath(FRAME, () => 1, 5, 10)).toBe(""); + }); +}); diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.ts new file mode 100644 index 00000000..7df819cf --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.ts @@ -0,0 +1,62 @@ +/** + * The coordinate space of a small time plot: t from 0 to a horizon across, + * a probability from 0 to 1 up, drawn in an SVG viewBox. + */ +export type PlotFrame = { + width: number; + height: number; + left: number; + right: number; + top: number; + bottom: number; + /** The last time on the axis. */ + horizon: number; +}; + +const NICE_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; + +/** The smallest round number at or above `x`: 1, 1.5, 2, 2.5, 3, 4, 5, 6 or 8 times a power of ten. */ +export function niceCeil(x: number): number { + if (!(x > 0) || !Number.isFinite(x)) { + return 1; + } + const power = 10 ** Math.floor(Math.log10(x)); + const step = NICE_STEPS.find((candidate) => candidate * power >= x * (1 - 1e-9)) ?? 10; + return step * power; +} + +/** How far across the plot sits time `t`. */ +export function xOf(frame: PlotFrame, t: number): number { + const span = frame.width - frame.left - frame.right; + return frame.left + (Math.min(Math.max(t, 0), frame.horizon) / frame.horizon) * span; +} + +/** How far down the plot sits probability `v`. */ +export function yOf(frame: PlotFrame, v: number): number { + const span = frame.height - frame.top - frame.bottom; + return frame.top + (1 - Math.min(Math.max(v, 0), 1)) * span; +} + +/** An SVG path through `curve(t)` for t from `from` to `to`, cut at the horizon. */ +export function curvePath( + frame: PlotFrame, + curve: (t: number) => number, + from: number, + to: number, + samples = 96, +): string { + const end = Math.min(to, frame.horizon); + if (end <= from) { + return ""; + } + const points: string[] = []; + for (let i = 0; i <= samples; i++) { + const t = from + ((end - from) * i) / samples; + points.push(`${round(xOf(frame, t))},${round(yOf(frame, curve(t)))}`); + } + return `M${points.join("L")}`; +} + +function round(value: number): number { + return Math.round(value * 10) / 10; +} diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/range-field.tsx b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/range-field.tsx new file mode 100644 index 00000000..ec1653c1 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/range-field.tsx @@ -0,0 +1,39 @@ +import { useId } from "react"; + +type RangeFieldProps = { + /** What the slider sets, with its symbol. */ + label: React.ReactNode; + value: number; + /** The value as read beside the label and by screen readers. */ + valueText: string; + min: number; + max: number; + step: number; + onChange: (value: number) => void; +}; + +/** A labelled range slider with its value read out beside the label. */ +export const RangeField: React.FC = ({ label, value, valueText, min, max, step, onChange }) => { + const id = useId(); + return ( +
+ + onChange(Number(event.currentTarget.value))} + /> +
+ ); +}; diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/survival-plot.tsx b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/survival-plot.tsx new file mode 100644 index 00000000..8dd59ee3 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/survival-plot.tsx @@ -0,0 +1,155 @@ +import { delayFromDraw, fireModifier, survival, threshold } from "./exponential"; +import { fixed } from "./format"; +import { modifierClass } from "./modifier-class"; +import { curvePath, niceCeil, xOf, yOf } from "./plot-frame"; +import { useWidth } from "./use-width"; + +import type { PlotFrame } from "./plot-frame"; + +type SurvivalPlotProps = { + lambda: number; + dt: number; + /** The last uniform draw, if any. */ + draw: number | undefined; +}; + +const HEIGHT = 164; + +/** The horizon that shows the step and the delay until only 5% of clocks are left. */ +function survivalHorizon(lambda: number, dt: number): number { + return niceCeil(Math.max(dt * 1.25, 3 / lambda)); +} + +/** Where a label under the axis starts, so that it stays beside the point it names and inside the plot. */ +function anchorNear(x: number, frame: PlotFrame, room: number): "start" | "middle" | "end" { + if (x - frame.left < room) { + return "start"; + } + return frame.width - frame.right - x < room ? "end" : "middle"; +} + +/** + * The survival S(t) = e^(-λt) with the step [0, Δt], the threshold θ = S(Δt) + * and the band [θ, 1] of draws that fire, and the last draw read both ways: + * u on the probability axis, τ = -ln(u)/λ on the time axis, joined through + * the curve. It draws at one unit per pixel of its measured width, so its + * text keeps its size whatever the panel's width. + */ +export const SurvivalPlot: React.FC = ({ lambda, dt, draw }) => { + const [width, measure] = useWidth(); + const frame: PlotFrame = { + width, + height: HEIGHT, + left: 38, + right: 12, + top: 24, + bottom: 36, + horizon: survivalHorizon(lambda, dt), + }; + const theta = threshold(lambda, dt); + const x0 = xOf(frame, 0); + const xEnd = xOf(frame, frame.horizon); + const xStep = xOf(frame, dt); + const yBase = yOf(frame, 0); + const yTop = yOf(frame, 1); + const yTheta = yOf(frame, theta); + const tickRow = yBase + 15; + const delayRow = yBase + 30; + + const tau = draw === undefined ? undefined : delayFromDraw(lambda, draw); + const tauInside = tau !== undefined && tau <= frame.horizon; + const xTau = tau === undefined ? x0 : xOf(frame, tau); + const yDraw = draw === undefined ? yBase : yOf(frame, draw); + + const description = + `Survival S(t) = e^(−${fixed(lambda, 0)}t) up to t = ${frame.horizon}. ` + + `The step ends at Δt = ${fixed(dt, 2)}, where S(Δt) = θ = ${fixed(theta, 3)}; draws above θ fire.` + + (draw === undefined || tau === undefined + ? "" + : ` The last draw u = ${fixed(draw, 3)} meets the curve at τ = ${fixed(tau, 2)}.`); + + return ( +
+ {width === 0 ? null : ( + + + S(t) = e^(−{fixed(lambda, 0)}t) + + + + + {yTheta - yTop > 30 ? ( + + p + + ) : null} + + + + + + + survival(lambda, t), 0, frame.horizon)} /> + + {yTheta - yTop >= 16 ? ( + + 1 + + ) : null} + + {fixed(theta, 2)} + + {yBase - yTheta >= 16 ? ( + + 0 + + ) : null} + + {xStep - x0 >= 22 ? ( + + 0 + + ) : null} + = 22 ? "middle" : "start"} + > + Δt + + {xEnd - xStep >= 60 ? ( + + t = {frame.horizon} + + ) : null} + + {draw === undefined || tau === undefined ? null : ( + + + {tauInside ? : null} + + {tauInside ? : null} + + u + + {tauInside ? ( + + τ = {fixed(tau, 2)} + + ) : ( + + τ = {fixed(tau, 2)} → + + )} + + )} + + )} +
+ ); +}; diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/use-width.ts b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/use-width.ts new file mode 100644 index 00000000..4e5ef9e7 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/use-width.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; + +/** + * The width of an element in CSS pixels, kept up to date by a ResizeObserver, + * so that a plot can draw at one pixel per unit and keep its text at a fixed + * size. It is 0 until the element is laid out. + */ +export function useWidth(): readonly [number, React.RefCallback] { + const [width, setWidth] = useState(0); + function observe(element: HTMLElement | null) { + if (element === null) { + return undefined; + } + const observer = new ResizeObserver(([entry]) => { + if (entry !== undefined) { + setWidth(entry.contentRect.width); + } + }); + observer.observe(element); + return () => observer.disconnect(); + } + return [width, observe]; +} diff --git a/pocs/zeroth-playground/examples/birth-death/meta.ts b/pocs/zeroth-playground/examples/birth-death/meta.ts new file mode 100644 index 00000000..d0b39ead --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Coins against clocks", + title: "Birth–death", + summary: "The same net in discrete steps and in continuous time.", + rung: "rates", + order: 60, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/birth-death/net.pn.yaml b/pocs/zeroth-playground/examples/birth-death/net.pn.yaml new file mode 100644 index 00000000..7203a41e --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/net.pn.yaml @@ -0,0 +1,16 @@ +name: birth_death_process +description: 'Births add to the population at rate 2, and each death removes one member at rate 1 while any remain.' +kind: stochastic + +places: + Population: + +transitions: + Birth: + outputs: + Population: + rate: 2 + Death: + inputs: + Population: + rate: 1 diff --git a/pocs/zeroth-playground/examples/birth-death/page.mdx b/pocs/zeroth-playground/examples/birth-death/page.mdx new file mode 100644 index 00000000..f9143b1d --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death/page.mdx @@ -0,0 +1,52 @@ +import { CoinClockExplorer } from "./components/coin-clock-explorer"; + +The compiler turns a rate into a coin tested each step, or into a clock in continuous time. The two agree draw by draw, but a coin fires at most once per step, so its rate stays below $\lambda$. + +Under **Rates** on coin, the default, `Transition_Birth` tests a draw against $\theta = e^{-\lambda \Delta t}$ each step. For rate 2 over a step of 1, $\theta = e^{-2}$: + +```python +return X(u_Birth) >= 0.1353352832366127 +``` + +Under **Rates** on clock, each transition owns a clock armed with `exp(rate)`. It fires when the clock reaches 0, then re-arms. The compiler writes that in the SPN theory: a clock `clk_T` and an event `ev_T` per transition, a Nat per place that moves when `fired` sees an event, and the clocks hidden in `compose`. `Transition_Birth` reads: + +```python +fires_Birth = clk_Birth == 0 +return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) +``` + +An `ite` with `None` as its last branch has no value where its condition fails: the event toggles when `Birth` fires and stays put otherwise. Its `flow` runs the clock down while it is not negative, and leaves the event still with `0`: + +```python +return ite(clk_Birth >= 0, -1 * d(t), None), 0 +``` + + + + + + + + + + +
CoinClock
Timesteps of $\Delta t$continuous
Fires when$u_T \ge \theta$`clk_T == 0`
Firingsat most one per stepone per expiry
+ +A draw $u$ stands for the delay $\tau = -\ln(u)/\lambda$, and $u \ge \theta$ exactly when $\tau \le \Delta t$. So the coin fires exactly when the clock would expire within the step. A clock can expire twice in one step, though, while a coin fires once, so the coin's rate $p/\Delta t$ stays below $\lambda$. It tends to $\lambda$ as $\Delta t$ shrinks. + +
+ +
+ +Under clocks, `Death` pauses its clock while `Population` is empty. *Two inputs, one firing* shows where that choice matters. + +### Options + +- **Rates** on clock gives the SPN modules above. +- **Time step** sets $\Delta t$, and with it each coin's threshold. + +### Open questions + + + Should a coin step allow ties? In one step `Birth` and `Death` can both fire, and `Place_Population` applies both. Under clocks two expiries at one instant have chance zero, and the place then applies neither. Both rules leave the same marking here. But where one firing disables another by adding a token, as through an inhibitor arc, the coin step fires both and clocks fire one. + diff --git a/pocs/zeroth-playground/examples/boiler/meta.ts b/pocs/zeroth-playground/examples/boiler/meta.ts new file mode 100644 index 00000000..969efe7d --- /dev/null +++ b/pocs/zeroth-playground/examples/boiler/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Continuous dynamics", + title: "Boiler", + summary: "An Euler step per token, and a read arc.", + rung: "colours", + order: 100, + listed: false, + options: { dt: 0.25 }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/boiler/net.pn.yaml b/pocs/zeroth-playground/examples/boiler/net.pn.yaml new file mode 100644 index 00000000..7ca74f77 --- /dev/null +++ b/pocs/zeroth-playground/examples/boiler/net.pn.yaml @@ -0,0 +1,33 @@ +name: boiler +kind: plain + +colours: + Vessel: + level: real + +dynamics: + Heat: + colour: Vessel + code: | + return tokens.map((t) => ({ level: 0.5 * (10 - t.level) })); + +places: + Tank: + colour: Vessel + dynamics: Heat + capacity: 1 + Alarms: + +transitions: + Alarm: + inputs: + Tank: + kind: read + outputs: + Alarms: + guard: | + return input.Tank[0].level >= 8; + +marking: + Tank: + - {level: 0} diff --git a/pocs/zeroth-playground/examples/boiler/page.mdx b/pocs/zeroth-playground/examples/boiler/page.mdx new file mode 100644 index 00000000..f8d3f697 --- /dev/null +++ b/pocs/zeroth-playground/examples/boiler/page.mdx @@ -0,0 +1,29 @@ +A token can carry a real attribute that follows a differential equation. `Tank` holds one `Vessel` token whose `level` $\ell$ follows $\dot{\ell} = 0.5\,(10 - \ell)$, and `Alarm` reads the tank through a read arc once the level reaches 8. + +### Refused here + +The equation and the guard are code strings, and the playground ships no code parser. So the compiler refuses the net with `code-not-parsed`, once per code string. + +With a parser, the monolithic step takes one Euler step of **Time step**, 0.25 here, for each token before the sweep: + +```python +Tank_0_level = ite(Tank_0_present, Tank_0_level + 0.125 * (10.0 - Tank_0_level), Tank_0_level) +``` + +The read arc becomes a test that takes no token: + +```python +fire_Alarm = Tank_0_present & (Tank_0_level >= 8.0) +``` + +**Shape** on modular refuses the net earlier, with `modular-coloured-not-lowered`, before it reads any code. + +### Open questions + + + Can an LRA flow read its own state, as $\dot{\ell} = f(\ell)$ needs? Until it can, dynamics take one Euler step per **Time step** inside `next`. + + + + How would SPN hold coloured tokens and dynamics: a `Real` sort, or an LRA module beside the SPN ones? Clocks do not apply to such nets today. + diff --git a/pocs/zeroth-playground/examples/bucket/meta.ts b/pocs/zeroth-playground/examples/bucket/meta.ts new file mode 100644 index 00000000..ae3785ee --- /dev/null +++ b/pocs/zeroth-playground/examples/bucket/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Slots and compaction", + title: "Bucket", + summary: "A coloured place as a row of positions.", + rung: "colours", + order: 120, + listed: false, + options: {}, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/bucket/net.pn.yaml b/pocs/zeroth-playground/examples/bucket/net.pn.yaml new file mode 100644 index 00000000..296b4c33 --- /dev/null +++ b/pocs/zeroth-playground/examples/bucket/net.pn.yaml @@ -0,0 +1,26 @@ +name: bucket +kind: stochastic + +colours: + Ball: + x: real + +places: + Pool: + colour: Ball + capacity: 2 + Done: + +transitions: + Take: + inputs: + Pool: + outputs: + Done: + rate: | + return 0.6 * (input.Pool[0].x / 100); + +marking: + Pool: + - {x: 2} + - {x: 0.5} diff --git a/pocs/zeroth-playground/examples/bucket/page.mdx b/pocs/zeroth-playground/examples/bucket/page.mdx new file mode 100644 index 00000000..14eff2ab --- /dev/null +++ b/pocs/zeroth-playground/examples/bucket/page.mdx @@ -0,0 +1,15 @@ +A coloured place becomes a row of positions. `Pool` holds two `Ball` tokens with a real `x`, and the rate of `Take` reads the first token: $\lambda = 0.6 \cdot x / 100$. + +### Refused here + +The rate is code, so the compiler refuses the net with `code-not-parsed`. + +With a parser, the monolithic step gives each position of `Pool` a present flag and an `x`: `Pool_0_present` and `Pool_0_x`. `Take` tries the tokens in order and fires on the first whose rate reaches an exponential draw, `e_Take`. At the end of the step the surviving tokens close up in position order. + +**Shape** on modular refuses the net with `modular-coloured-not-lowered`, before it reads any code. + +### Open questions + + + How should a coloured place split into modules, so that **Shape** on modular applies to every net? The modular shape refuses coloured places and dynamics today. + diff --git a/pocs/zeroth-playground/examples/cafe-queue/meta.ts b/pocs/zeroth-playground/examples/cafe-queue/meta.ts new file mode 100644 index 00000000..6db474f0 --- /dev/null +++ b/pocs/zeroth-playground/examples/cafe-queue/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Two inputs, one firing", + title: "Café queue", + summary: "A transition that takes from two places at once.", + rung: "rates", + order: 70, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml b/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml new file mode 100644 index 00000000..499e01a3 --- /dev/null +++ b/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml @@ -0,0 +1,33 @@ +name: cafe_queue +description: 'Customers arrive, wait for a free member of staff, are served and leave.' +kind: stochastic + +places: + Waiting: + FreeStaff: + Serving: + Served: + +transitions: + Arrive: + outputs: + Waiting: + rate: 1.2 + BeginService: + inputs: + Waiting: + FreeStaff: + outputs: + Serving: + rate: 0.4 + FinishService: + inputs: + Serving: + outputs: + Served: + FreeStaff: + rate: 0.5 + +marking: + Waiting: 3 + FreeStaff: 2 diff --git a/pocs/zeroth-playground/examples/cafe-queue/page.mdx b/pocs/zeroth-playground/examples/cafe-queue/page.mdx new file mode 100644 index 00000000..3adb8956 --- /dev/null +++ b/pocs/zeroth-playground/examples/cafe-queue/page.mdx @@ -0,0 +1,30 @@ +`BeginService` needs a customer in `Waiting` and a free member of staff in `FreeStaff`. Under coins that is two tests in one guard. Under clocks the compiler must also decide what an empty input does to the clock. + +Under **Rates** on coin, the default, `Transition_BeginService` tests both places beside its draw: + +```python +return (Waiting >= 1.0) & (FreeStaff >= 1.0) & (X(u_BeginService) >= 0.6703200460356393) +``` + +Under **Rates** on clock, the transition decides. `fires_BeginService` keeps both tests, and the clock runs down only while both places hold a token: + +```python +fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) +``` + +Zeroth's own `birth_death.py` lets the places decide instead. The clock always runs, and at expiry each input place gives a token if it has one. With one input place the two rules agree. With two they do not: `BeginService` could take a customer from `Waiting` while `FreeStaff` is empty. + +### Options + +- **Rates** on clock gives the SPN modules above. +- **Marking** on Int keeps the places Int, and the guard reads `X(hit_BeginService)`, a Bool from `Draw_BeginService`, in place of the draw. + +### Open questions + + + Under clocks, should the transition decide, as `fires_BeginService` does, or the places, as in `birth_death.py`? Under the places' rule `BeginService` can fire with `FreeStaff` empty, so the staff, `FreeStaff + Serving`, can grow past the two it starts with. + + + + Do SPN and LRA modules compose in one system? Clocked arrivals beside a coin model of the service would need it. + diff --git a/pocs/zeroth-playground/examples/capacity/meta.ts b/pocs/zeroth-playground/examples/capacity/meta.ts new file mode 100644 index 00000000..2ea1e9a9 --- /dev/null +++ b/pocs/zeroth-playground/examples/capacity/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Capacity within a step", + title: "Capacity", + summary: "A later producer sees the token an earlier one added.", + rung: "steps", + order: 20, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/capacity/net.pn.yaml b/pocs/zeroth-playground/examples/capacity/net.pn.yaml new file mode 100644 index 00000000..ac4e0c7f --- /dev/null +++ b/pocs/zeroth-playground/examples/capacity/net.pn.yaml @@ -0,0 +1,30 @@ +name: capacity +kind: plain + +places: + LeftSource: + RightSource: + Buffer: + capacity: 2 + Sink: + +transitions: + PutLeft: + inputs: + LeftSource: + outputs: + Buffer: + PutRight: + inputs: + RightSource: + outputs: + Buffer: + Take: + inputs: + Buffer: + outputs: + Sink: + +marking: + LeftSource: 5 + RightSource: 5 diff --git a/pocs/zeroth-playground/examples/capacity/page.mdx b/pocs/zeroth-playground/examples/capacity/page.mdx new file mode 100644 index 00000000..79c61631 --- /dev/null +++ b/pocs/zeroth-playground/examples/capacity/page.mdx @@ -0,0 +1,27 @@ +`Buffer` holds at most two tokens, and `PutLeft` and `PutRight` can both fill it in one step. So the second producer must count the token the first one adds, although that token lands only at the end of the step. + +`PutLeft` comes first, so it tests the marking: `Buffer + 1 <= 2`. `PutRight` tests `fill_Buffer + 1 <= 2`, where the local `fill_Buffer` is what `Buffer` would hold if the step ended now: + +```python +fill_Buffer = Buffer +fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) +return (RightSource >= 1) & (fill_Buffer + 1 <= 2) +``` + +These are the lines of `Transition_PutRight`. It rebuilds the fill from `X(fire_PutLeft)`, while `Place_Buffer` alone writes `Buffer`. In the monolithic shape one `fill_Buffer` runs through the sweep, and every firing that moves tokens in `Buffer` updates it. + +`Take` comes after both producers in record order, so the room it frees counts only from the next step. A full `Buffer` that `Take` drains in this step still blocks both producers. + +### Options + +- **Shape** on monolithic gives one `Capacity` class: `fill_Buffer = Buffer` before the sweep, and the class returns `fill_Buffer` as the new `Buffer`. + +### Open questions + + + How can SPN express a capacity: an ordering on `Nat`, or a complement place? SPN tests a count against zero only, so **Rates** on clock refuses a capacity with `clocks-capacity`. + + + + Should a capacity test count the room a later transition frees in the same step? Today it counts only the transitions before it in record order. + diff --git a/pocs/zeroth-playground/examples/catalog.test.ts b/pocs/zeroth-playground/examples/catalog.test.ts new file mode 100644 index 00000000..81d4f60f --- /dev/null +++ b/pocs/zeroth-playground/examples/catalog.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { EXAMPLES, LADDER, folderOf } from "./catalog"; + +/** The folders a glob matched, sorted. */ +function foldersOf(matches: Record): string[] { + return Object.keys(matches).map(folderOf).toSorted(); +} + +describe("the example folders", () => { + it("each hold a meta.ts, a net.pn.yaml and a page.mdx", () => { + // GIVEN the folders that hold a meta.ts + const metas = foldersOf(import.meta.glob("./*/meta.ts")); + // THEN the same folders hold a net.pn.yaml and a page.mdx, and each is an example + expect(foldersOf(import.meta.glob("./*/net.pn.yaml"))).toEqual(metas); + expect(foldersOf(import.meta.glob("./*/page.mdx"))).toEqual(metas); + expect(EXAMPLES.map((example) => example.id).toSorted()).toEqual(metas); + }); + + it("climb the ladder rung by rung, each at its own place", () => { + // GIVEN the examples in their order + const rungs = LADDER.map((rung) => rung.id); + // WHEN each is placed on its rung + const placed = EXAMPLES.map((example) => rungs.indexOf(example.rung)); + // THEN the rungs never go down, and no two examples share an order + expect(placed).toEqual(placed.toSorted((a, b) => a - b)); + expect(new Set(EXAMPLES.map((example) => example.order)).size).toBe(EXAMPLES.length); + }); +}); diff --git a/pocs/zeroth-playground/examples/catalog.ts b/pocs/zeroth-playground/examples/catalog.ts new file mode 100644 index 00000000..cf218cd7 --- /dev/null +++ b/pocs/zeroth-playground/examples/catalog.ts @@ -0,0 +1,78 @@ +import type { CompilerOptions } from "../compiler"; + +/** A rung of the ladder: the examples it holds tackle features of one kind. */ +export type Rung = { + id: "steps" | "rates" | "colours"; + title: string; + about: string; +}; + +/** The ladder, from the plainest net up. */ +export const LADDER: readonly Rung[] = [ + { id: "steps", title: "Steps", about: "Plain nets: how one step of the net becomes modules" }, + { id: "rates", title: "Rates", about: "Stochastic nets: coins tested each step, or clocks in continuous time" }, + { id: "colours", title: "Coloured tokens", about: "Attributes, dynamics and kernels; not compiled here yet" }, +]; + +/** What an example's `meta.ts` declares. */ +export type ExampleMeta = { + /** What the example tackles: its name in the picker and the heading of its page. */ + feature: string; + /** The net it uses, named above the page's heading. */ + title: string; + /** One line on what the example shows. */ + summary: string; + rung: Rung["id"]; + /** Where it sits up the ladder: examples sort by it, lowest first. */ + order: number; + /** + * Shown in the picker. An unlisted example still opens from its URL hash, + * and shows in the picker while it is the one open. + */ + listed: boolean; + /** + * The compiler options the example opens with; the panel edits them from + * there. The playground opens a net in the modular shape wherever that shape + * compiles it: coloured nets and nets with dynamics stay monolithic. + */ + options: CompilerOptions; +}; + +/** + * An example as the app reads it: its folder's metadata, the folder's name + * as its id (the picker's key, the page's key, the URL hash), and its IR. + */ +export type Example = ExampleMeta & { + id: string; + /** The IR document; the options sit beside it, in `options`. */ + ir: string; +}; + +// One folder per example: `meta.ts`, `net.pn.yaml` and `page.mdx`. +const METAS = import.meta.glob("./*/meta.ts", { eager: true, import: "default" }); +const NETS = import.meta.glob("./*/net.pn.yaml", { eager: true, query: "?raw", import: "default" }); + +/** The folder a globbed path sits in: `./queue/meta.ts` is `queue`. */ +export function folderOf(path: string): string { + return path.split("/").at(-2) ?? path; +} + +function exampleAt(path: string, meta: ExampleMeta): Example { + const id = folderOf(path); + const ir = NETS[`./${id}/net.pn.yaml`]; + if (ir === undefined) { + throw new Error(`examples/${id} has a meta.ts but no net.pn.yaml`); + } + return { ...meta, id, ir }; +} + +/** The examples up the ladder, in their `order`. */ +export const EXAMPLES: readonly Example[] = Object.entries(METAS) + .map(([path, meta]) => exampleAt(path, meta)) + .toSorted((a, b) => a.order - b.order); + +export const FIRST_EXAMPLE: Example = EXAMPLES[0] as Example; + +export function exampleById(id: string): Example | undefined { + return EXAMPLES.find((example) => example.id === id); +} diff --git a/pocs/zeroth-playground/examples/conflict/meta.ts b/pocs/zeroth-playground/examples/conflict/meta.ts new file mode 100644 index 00000000..90d2151d --- /dev/null +++ b/pocs/zeroth-playground/examples/conflict/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Two transitions, one place", + title: "Conflict", + summary: "Who takes the tokens: the sweep, a pick or a controller.", + rung: "steps", + order: 30, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/conflict/net.pn.yaml b/pocs/zeroth-playground/examples/conflict/net.pn.yaml new file mode 100644 index 00000000..a6f58905 --- /dev/null +++ b/pocs/zeroth-playground/examples/conflict/net.pn.yaml @@ -0,0 +1,24 @@ +name: conflict +kind: plain + +places: + Pool: + Left: + Right: + +transitions: + TakeLeft: + inputs: + Pool: + weight: 2 + outputs: + Left: + controllable: true + TakeRight: + inputs: + Pool: + outputs: + Right: + +marking: + Pool: 3 diff --git a/pocs/zeroth-playground/examples/conflict/page.mdx b/pocs/zeroth-playground/examples/conflict/page.mdx new file mode 100644 index 00000000..cb50a244 --- /dev/null +++ b/pocs/zeroth-playground/examples/conflict/page.mdx @@ -0,0 +1,36 @@ +`TakeLeft` takes two tokens from `Pool` and `TakeRight` takes one, so when `Pool` runs low only one of them can fire. The compiler has to say which one gets the tokens. + +By default, **Conflicts** on sweep, record order decides, as in one step of the net. `TakeLeft` tests `Pool >= 2`, and `TakeRight` tests what `TakeLeft` left, `avail_Pool`: + +```python +avail_Pool = Pool +avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 2, avail_Pool) +return avail_Pool >= 1 +``` + +With **Conflicts** on nondet, each transition in the conflict also waits for an input Bool that nothing drives, `pick_TakeLeft` or `pick_TakeRight`. The two guards become: + +```python +return (Pool >= 2) & X(pick_TakeLeft) +return (avail_Pool >= 1) & X(pick_TakeRight) +``` + +Each way of resolving the conflict is then a run of the modules, and all picks true is the sweep. + +`TakeLeft` is also controllable. **Control** on open adds the same kind of input to its guard, `X(go_TakeLeft)`. + +### Options + +- **Conflicts** on nondet adds the picks. +- **Control** on open adds `go_TakeLeft`. +- **Shape** on monolithic gives one `Conflict` class whose `next` takes from `Pool` in record order, with no `avail_Pool`. + +### Open questions + + + Should read and inhibitor arcs count as conflicts? Today any two transitions that share an input place get picks, so two readers of one place get picks although they take nothing. + + + + Are **Control** on open and **Conflicts** on nondet one mechanism? Both add an input Bool that nothing drives to a guard, `X(go_T)` or `X(pick_T)`. One option for external choices could replace both. + diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step.css b/pocs/zeroth-playground/examples/cycle/components/one-step.css new file mode 100644 index 00000000..0c4fa800 --- /dev/null +++ b/pocs/zeroth-playground/examples/cycle/components/one-step.css @@ -0,0 +1,105 @@ +/* -- One step of the cycle: a marking, the firing, the next marking -------- */ + +.one-step { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 0.5rem; + font-family: var(--sw-mono); + font-size: 0.72rem; + line-height: 1.3; +} + +.one-step__marking { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.45rem; + padding: 0.5rem 0.4rem 0.55rem; + border: 1px solid var(--sw-hair-2); + border-radius: 2px; + background: var(--sw-paper-2); +} + +.one-step__label { + color: var(--sw-ink-2); +} + +.one-step__places { + display: flex; + gap: 0.9rem; +} + +.one-step__place { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; +} + +.one-step__circle { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border: 1.25px solid var(--sw-ink); + border-radius: 50%; + background: var(--sw-paper-2); +} + +.one-step__token { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--sw-accent); +} + +.one-step__name { + color: var(--sw-ink); + font-weight: 500; +} + +.one-step__firing { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + text-align: center; +} + +.one-step__fires { + color: var(--sw-accent); + font-weight: 600; +} + +.one-step__waits { + color: var(--sw-ink-2); +} + +.one-step__arrow::before { + content: "→"; + color: var(--sw-ink-2); + font-size: 1rem; + line-height: 1; +} + +/* Too narrow for three columns: the markings stack, with the firing between them. */ +@container (width < 17rem) { + .one-step { + grid-template-columns: 1fr; + } + + .one-step__firing { + flex-direction: row; + justify-content: center; + gap: 0.6rem; + } + + .one-step__arrow { + order: -1; + } + + .one-step__arrow::before { + content: "↓"; + } +} diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step.tsx b/pocs/zeroth-playground/examples/cycle/components/one-step.tsx new file mode 100644 index 00000000..26f1083a --- /dev/null +++ b/pocs/zeroth-playground/examples/cycle/components/one-step.tsx @@ -0,0 +1,33 @@ +import { Marking } from "./one-step/marking"; + +import "./one-step.css"; + +/** + * One step of the cycle from A 1, B 0: Go fires and Back waits, so the step + * ends at A 0, B 1. Static, since the step is one update and not a motion. + */ +export const OneStep: React.FC = () => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx b/pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx new file mode 100644 index 00000000..2e3ac7d5 --- /dev/null +++ b/pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx @@ -0,0 +1,29 @@ +type MarkingProps = { + /** When the marking holds, as read above it. */ + label: string; + /** The tokens in each place, in the order the places are drawn. */ + places: readonly { name: string; tokens: number }[]; +}; + +/** A marking drawn as places side by side, each with a dot per token and its name under it. */ +export const Marking: React.FC = ({ label, places }) => { + return ( +
+ {label} +
+ {places.map(({ name, tokens }) => ( + + + {Array.from({ length: tokens }, (_, index) => ( + + ))} + + + {name} {tokens} + + + ))} +
+
+ ); +}; diff --git a/pocs/zeroth-playground/examples/cycle/meta.ts b/pocs/zeroth-playground/examples/cycle/meta.ts new file mode 100644 index 00000000..811247c3 --- /dev/null +++ b/pocs/zeroth-playground/examples/cycle/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "One step as a module", + title: "Cycle", + summary: "How one step of a plain net becomes one module, or several.", + rung: "steps", + order: 10, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/cycle/net.pn.yaml b/pocs/zeroth-playground/examples/cycle/net.pn.yaml new file mode 100644 index 00000000..2200ff57 --- /dev/null +++ b/pocs/zeroth-playground/examples/cycle/net.pn.yaml @@ -0,0 +1,21 @@ +name: cycle +kind: plain + +places: + A: + B: + +transitions: + Go: + inputs: + A: + outputs: + B: + Back: + inputs: + B: + outputs: + A: + +marking: + A: 1 diff --git a/pocs/zeroth-playground/examples/cycle/page.mdx b/pocs/zeroth-playground/examples/cycle/page.mdx new file mode 100644 index 00000000..d8592ef4 --- /dev/null +++ b/pocs/zeroth-playground/examples/cycle/page.mdx @@ -0,0 +1,38 @@ +import { OneStep } from "./components/one-step"; + +One step of a plain net fires every enabled transition once, in record order. A firing takes its input tokens at once, and its output tokens land at the end of the step, so no token is produced and taken in the same step. + +
+ +
+ +With **Shape** on monolithic, the compiler writes the step as one class, `Cycle`, with an Int per place. Its `next` method is one step of the net: + +```python +fire_Go = A >= 1 +A = ite(fire_Go, A - 1, A) +fire_Back = B >= 1 +B = ite(fire_Back, B - 1, B) +A = ite(fire_Back, A + 1, A) +B = ite(fire_Go, B + 1, B) +``` + +The first four lines test and take in record order. The last two land the produced tokens. + +The playground opens the example with **Shape** on modular. It splits the same step into one module per transition and one per place, joined by `compose`. `Transition_Go` writes the Bool `fire_Go` as `A >= 1`. `Place_A` applies every firing that moves `A`: + +```python +A = ite(X(fire_Go), A - 1, A) +A = ite(X(fire_Back), A + 1, A) +``` + +`X(fire_Go)` reads the value `fire_Go` takes in this step, so `Place_A` waits for `Transition_Go`. In Zeroth a variable has one writer, so each place gets a module of its own. + +### Options + +- **Shape** on monolithic gives the class `Cycle` above. +- **Files** on one per module writes `net.py` and a file per module, such as `transition_go.py`. + +### Hover + +- Point at a line in either editor, or at a node in the preview, to light the lines and nodes that match it. `Go:` lights `fire_Go`, `Transition_Go` and the line that builds `transition_Go`. diff --git a/pocs/zeroth-playground/examples/drones/meta.ts b/pocs/zeroth-playground/examples/drones/meta.ts new file mode 100644 index 00000000..509699e2 --- /dev/null +++ b/pocs/zeroth-playground/examples/drones/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Tokens with attributes", + title: "Drones", + summary: "Coloured positions that kernels write.", + rung: "colours", + order: 110, + listed: false, + options: {}, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/drones/net.pn.yaml b/pocs/zeroth-playground/examples/drones/net.pn.yaml new file mode 100644 index 00000000..39090fc0 --- /dev/null +++ b/pocs/zeroth-playground/examples/drones/net.pn.yaml @@ -0,0 +1,53 @@ +name: drones +kind: mixed + +colours: + Drone: + battery: real + state: + enum: + - idle + - flying + +dynamics: + Drain: + colour: Drone + code: | + return tokens.map((d) => ({ battery: -2.5 })); + +places: + Hangar: + colour: Drone + capacity: 3 + Airborne: + colour: Drone + capacity: 3 + dynamics: Drain + Sorties: + +transitions: + Launch: + inputs: + Hangar: + outputs: + Airborne: + Sorties: + rate: | + return 0.6 * (input.Hangar[0].battery / 100); + kernel: | + const d = input.Hangar[0]; + return { Airborne: [{ battery: d.battery, state: "flying" }] }; + Land: + inputs: + Airborne: + outputs: + Hangar: + guard: | + return input.Airborne[0].battery < 20 || input.Airborne[0].state !== "flying"; + kernel: | + return { Hangar: [{ battery: 100, state: "idle" }] }; + +marking: + Hangar: + - {battery: 100, state: idle} + - {battery: 60, state: idle} diff --git a/pocs/zeroth-playground/examples/drones/page.mdx b/pocs/zeroth-playground/examples/drones/page.mdx new file mode 100644 index 00000000..0383cdcd --- /dev/null +++ b/pocs/zeroth-playground/examples/drones/page.mdx @@ -0,0 +1,19 @@ +Tokens can carry attributes that kernels write. A `Drone` has a real `battery` and a `state`, `idle` or `flying`. `Launch` fires at a rate that reads the battery and writes a flying drone into `Airborne`. `Drain` lowers the battery of every airborne drone, and `Land` brings a drone back with a full battery. + +### Refused here + +The rate, the guard, the two kernels and the equation are code strings. So the compiler refuses the net with `code-not-parsed`: five diagnostics for three items. + +With a parser, the monolithic step lays each coloured place out as a row of positions. Each position has a present flag and one variable per attribute: `Hangar_0_present`, `Hangar_0_battery` and `Hangar_0_state`. The string `state` is held as a Real code, 0 for `idle` and 1 for `flying`. A kernel writes locals named `out_Launch_Airborne_0_*`, which land after the surviving tokens close up. + +**Shape** on modular refuses the net with `modular-coloured-not-lowered` alone, before it reads any code. + +### Open questions + + + How would a kernel compile under SPN, which has no sort for token attributes? + + + + `state` compiles because its colour lists its values. A `uuid` or an open string gets no variable, and code that reads one is refused with `attribute-not-lowerable`. Do proofs need them? + diff --git a/pocs/zeroth-playground/examples/examples.test.ts b/pocs/zeroth-playground/examples/examples.test.ts new file mode 100644 index 00000000..98785bac --- /dev/null +++ b/pocs/zeroth-playground/examples/examples.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { compile } from "../compiler"; +import { EXAMPLES } from "./catalog"; + +/** Each example compiled under the options it opens with, as the playground opens it. */ +const OPENED = EXAMPLES.map((example) => ({ + example, + compilation: compile(example.ir, { options: example.options }), +})); + +describe("the examples", () => { + it("each compile, or are refused as expected, under the options they open with", () => { + // GIVEN every example compiled under the options it opens with + // WHEN each outcome is read off its compilation + const outcomes = Object.fromEntries( + OPENED.map(({ example, compilation: { files, errors } }) => [ + example.id, + files.length > 0 ? "compiles" : errors.map((error) => error.code).join(","), + ]), + ); + // THEN the plain and stochastic nets compile, and the nets with code wait for a parser + expect(outcomes).toEqual({ + cycle: "compiles", + capacity: "compiles", + conflict: "compiles", + fork: "compiles", + "fork-clocked": "compiles", + queue: "compiles", + "birth-death": "compiles", + "cafe-queue": "compiles", + "sir-model": "compiles", + boiler: "code-not-parsed,code-not-parsed", + drones: "code-not-parsed,code-not-parsed,code-not-parsed,code-not-parsed,code-not-parsed", + bucket: "code-not-parsed", + }); + }); + + it("each open with options that apply to their net", () => { + // GIVEN every example compiled under the options it opens with + // THEN no option is dropped, so the panel shows every one + const dropped = OPENED.flatMap(({ example, compilation }) => + compilation.warnings.map((warning) => `${example.id}: ${warning.message}`), + ); + expect(dropped).toEqual([]); + }); + + for (const { example, compilation } of OPENED) { + for (const file of compilation.files) { + it(`write ${example.id}'s ${file.path} as the playground opens it`, async () => { + // GIVEN the example compiled under the options it opens with + // THEN the Python matches the snapshot beside the other examples' files + await expect(file.text).toMatchFileSnapshot(`./__snapshots__/${example.id}/${file.path}`); + }); + } + } +}); diff --git a/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.css b/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.css new file mode 100644 index 00000000..87fcd98a --- /dev/null +++ b/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.css @@ -0,0 +1,209 @@ +/* -- Two clocks race for Pool: bars run down to 0, the first to reach it fires -- + + One loop of 6.6 s, each bar's length its clock's time left, out of 3 time + units. Both start armed: TakeLeft with 2.4, TakeRight with 1.0. + 1.0 s TakeRight reaches 0: it takes a token and re-arms with 2.1. + 2.4 s TakeLeft reaches 0: it takes a token and re-arms with 2.7. + 3.1 s TakeRight reaches 0 and takes the last token, re-arming with 1.5. + Pool is empty, so both clocks wait until the loop starts again. */ + +.clock-race { + --race-loop: 6.6s; + + display: flex; + flex-direction: column; + gap: 0.55rem; + font-family: var(--sw-mono); + font-size: 0.72rem; + line-height: 1.3; +} + +.clock-race__head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.2rem; +} + +.clock-race__name { + color: var(--sw-ink); + font-weight: 500; +} + +.clock-race__fires { + color: var(--sw-accent); + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0; +} + +.clock-race__track { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.clock-race__zero { + color: var(--sw-ink-2); +} + +.clock-race__rail { + flex: 1; + height: 12px; + border: 1px solid var(--sw-hair); + background: var(--sw-paper); +} + +/* The bar shrinks toward the 0 at its left, as its clock runs down. */ +.clock-race__bar { + display: block; + height: 100%; + background: var(--sw-accent); + transform-origin: left center; + animation: var(--race-loop) linear infinite; +} + +.clock-race__clock--TakeLeft .clock-race__bar { + animation-name: clock-race-left; +} + +.clock-race__clock--TakeRight .clock-race__bar { + animation-name: clock-race-right; +} + +.clock-race__clock--TakeLeft .clock-race__fires { + animation: clock-race-left-fires var(--race-loop) step-end infinite; +} + +.clock-race__clock--TakeRight .clock-race__fires { + animation: clock-race-right-fires var(--race-loop) step-end infinite; +} + +@keyframes clock-race-left { + 0% { transform: scaleX(0.8); } + 36.36% { transform: scaleX(0); } + 36.4% { transform: scaleX(0.9); } + 46.97% { transform: scaleX(0.667); } + 100% { transform: scaleX(0.667); } +} + +@keyframes clock-race-right { + 0% { transform: scaleX(0.333); } + 15.15% { transform: scaleX(0); } + 15.19% { transform: scaleX(0.7); } + 46.97% { transform: scaleX(0); } + 47.01% { transform: scaleX(0.5); } + 100% { transform: scaleX(0.5); } +} + +@keyframes clock-race-left-fires { + 0% { opacity: 0; } + 36.36% { opacity: 1; } + 45% { opacity: 0; } +} + +@keyframes clock-race-right-fires { + 0% { opacity: 0; } + 15.15% { opacity: 1; } + 25% { opacity: 0; } + 46.97% { opacity: 1; } + 57% { opacity: 0; } +} + +.clock-race__pool { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.15rem; +} + +/* The tokens and the note share one place: the note shows once the last token is taken. */ +.clock-race__contents { + display: grid; + align-items: center; +} + +.clock-race__tokens, +.clock-race__wait { + grid-area: 1 / 1; +} + +.clock-race__tokens { + display: flex; + gap: 0.35rem; +} + +.clock-race__token { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--sw-accent); + animation: var(--race-loop) step-end infinite; +} + +.clock-race__token--3 { + animation-name: clock-race-take-first; +} + +.clock-race__token--2 { + animation-name: clock-race-take-second; +} + +.clock-race__token--1 { + animation-name: clock-race-take-third; +} + +.clock-race__wait { + color: var(--sw-ink-2); + visibility: hidden; + animation: clock-race-wait var(--race-loop) step-end infinite; +} + +@keyframes clock-race-take-first { + 0% { visibility: visible; } + 15.15% { visibility: hidden; } + 100% { visibility: hidden; } +} + +@keyframes clock-race-take-second { + 0% { visibility: visible; } + 36.36% { visibility: hidden; } + 100% { visibility: hidden; } +} + +@keyframes clock-race-take-third { + 0% { visibility: visible; } + 46.97% { visibility: hidden; } + 100% { visibility: hidden; } +} + +@keyframes clock-race-wait { + 0% { visibility: hidden; } + 46.97% { visibility: visible; } + 100% { visibility: visible; } +} + +/* Without motion: the moment TakeRight reaches 0 first and takes a token, while TakeLeft keeps its time. */ +@media (prefers-reduced-motion: reduce) { + .clock-race :is(.clock-race__bar, .clock-race__fires, .clock-race__token, .clock-race__wait) { + animation: none; + } + + .clock-race__clock--TakeLeft .clock-race__bar { + transform: scaleX(0.467); + } + + .clock-race__clock--TakeRight .clock-race__bar { + transform: scaleX(0); + } + + .clock-race__clock--TakeRight .clock-race__fires { + opacity: 1; + } + + .clock-race__token--3 { + visibility: hidden; + } +} diff --git a/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.tsx b/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.tsx new file mode 100644 index 00000000..6c875e18 --- /dev/null +++ b/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.tsx @@ -0,0 +1,44 @@ +import "./clock-race.css"; + +/** The racers, in the order they are drawn: each bar's animation is in the CSS, keyed by its id. */ +const CLOCKS = ["TakeLeft", "TakeRight"] as const; + +/** Pool's tokens, each taken at the firing its modifier names in the CSS. */ +const TOKENS = 3; + +/** + * TakeLeft and TakeRight race for Pool's tokens. Both clocks run down at the + * same speed toward 0; the first to reach 0 fires, takes a token and re-arms, + * while the other keeps its time. With Pool empty, both clocks wait. + */ +export const ClockRace: React.FC = () => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/examples/fork-clocked/meta.ts b/pocs/zeroth-playground/examples/fork-clocked/meta.ts new file mode 100644 index 00000000..f0d343a8 --- /dev/null +++ b/pocs/zeroth-playground/examples/fork-clocked/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Conflicts under clocks", + title: "Fork under clocks", + summary: "A conflict as a race between clocks, with picks read at expiry.", + rung: "rates", + order: 90, + listed: true, + options: { rates: "clock", conflicts: "nondet" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml b/pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml new file mode 100644 index 00000000..e1b1808d --- /dev/null +++ b/pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml @@ -0,0 +1,30 @@ +name: fork +kind: stochastic + +places: + Pool: + Left: + Right: + +transitions: + TakeLeft: + inputs: + Pool: + outputs: + Left: + rate: 1 + TakeRight: + inputs: + Pool: + outputs: + Right: + rate: 2 + Return: + inputs: + Left: + outputs: + Pool: + rate: 0.5 + +marking: + Pool: 3 diff --git a/pocs/zeroth-playground/examples/fork-clocked/page.mdx b/pocs/zeroth-playground/examples/fork-clocked/page.mdx new file mode 100644 index 00000000..ebfd9c32 --- /dev/null +++ b/pocs/zeroth-playground/examples/fork-clocked/page.mdx @@ -0,0 +1,42 @@ +import { ClockRace } from "./components/clock-race"; + +Under clocks a conflict is a race. `TakeLeft` and `TakeRight` both take from `Pool`, and whichever clock reaches 0 first takes a token. + +
+ +
+ +With **Conflicts** on nondet, each taker also needs its pick when its clock expires: + +```python +fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft +``` + +A pick is an input that nothing drives, read at expiry. `Return` is in no conflict and has none. A false pick stops time: the clock stays at 0, and no time passes until the pick turns true. + +`Place_Pool` applies one firing at a time. Each case of its `ite` needs the other events off: + +```python +fired_TakeLeft & ~fired_Return & ~fired_TakeRight & (Pool != 0) +``` + +So when both takers expire together, `Left` and `Right` each gain a token while `Pool` loses none. + +### Options + +- **Conflicts** on sweep drops the picks: the first clock to expire fires. +- **Rates** on coin compiles the same net in steps of **Time step**, with a coin per transition. + +### Open questions + + + A false pick at expiry stops time. Should the clock pause while its pick is false, or re-arm at expiry whatever the pick? + + + + Two takers that expire together both produce, and `Pool` loses no token. Under exponential clocks a tie has chance zero, so this matters only where delays can be equal. Should `Place_Pool` apply both takes on a tie, or should a tie fire one transition? + + + + How is an input that nothing drives, a pick or a controller's choice, handled under SPN? The sugar has no helper for `Nondet`, and `simulate()` takes no input but `t`, so this output does not run there. **Control** on open does not apply under clocks for the same reason. + diff --git a/pocs/zeroth-playground/examples/fork/meta.ts b/pocs/zeroth-playground/examples/fork/meta.ts new file mode 100644 index 00000000..a5aca010 --- /dev/null +++ b/pocs/zeroth-playground/examples/fork/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Leaving a conflict open", + title: "Fork", + summary: "Only the transitions in a conflict get picks.", + rung: "steps", + order: 40, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/fork/net.pn.yaml b/pocs/zeroth-playground/examples/fork/net.pn.yaml new file mode 100644 index 00000000..b1112f8a --- /dev/null +++ b/pocs/zeroth-playground/examples/fork/net.pn.yaml @@ -0,0 +1,27 @@ +name: fork +kind: plain + +places: + Pool: + Left: + Right: + +transitions: + TakeLeft: + inputs: + Pool: + outputs: + Left: + TakeRight: + inputs: + Pool: + outputs: + Right: + Return: + inputs: + Left: + outputs: + Pool: + +marking: + Pool: 3 diff --git a/pocs/zeroth-playground/examples/fork/page.mdx b/pocs/zeroth-playground/examples/fork/page.mdx new file mode 100644 index 00000000..19db5264 --- /dev/null +++ b/pocs/zeroth-playground/examples/fork/page.mdx @@ -0,0 +1,10 @@ +A conflict involves only the transitions that share an input place. `TakeLeft` and `TakeRight` both take from `Pool`, while `Return` takes from `Left`, which no other transition takes from. + +So **Conflicts** on nondet gives a pick to `TakeLeft` and `TakeRight` and none to `Return`. `Transition_TakeLeft` adds `X(pick_TakeLeft)` to its test, and `Transition_Return` keeps its plain guard, `Left >= 1`. + +A property proved under nondet holds for every way the picks resolve the conflict, while `Return` fires whenever `Left` holds a token. Under the default, **Conflicts** on sweep, record order decides: `TakeRight` tests `avail_Pool`, the tokens `TakeLeft` left. + +### Options + +- **Conflicts** on nondet adds `pick_TakeLeft` and `pick_TakeRight`. +- **Shape** on monolithic gives one `Fork` class whose `next` takes from `Pool` in record order. diff --git a/pocs/zeroth-playground/examples/queue/components/coin-draws.css b/pocs/zeroth-playground/examples/queue/components/coin-draws.css new file mode 100644 index 00000000..fc4be533 --- /dev/null +++ b/pocs/zeroth-playground/examples/queue/components/coin-draws.css @@ -0,0 +1,103 @@ +/* -- Arrive's coin: one draw per step on [0, 1], against the threshold ----- */ + +.coin-draws { + --coin-step: 1.6s; + + font-family: var(--sw-mono); +} + +/* The readout names the step, the draw and what it decides; one line at a time. */ +.coin-draws__readout { + display: grid; + font-size: 0.72rem; + line-height: 1.4; +} + +.coin-draws__step, +.coin-draws__summary { + grid-area: 1 / 1; +} + +.coin-draws__step--fires { + color: var(--sw-accent); +} + +.coin-draws__step--waits { + color: var(--sw-ink-2); +} + +.coin-draws__summary { + display: none; + color: var(--sw-ink); +} + +.coin-draws__axis { + display: block; + margin-top: 0.2rem; + overflow: visible; +} + +.coin-draws__region, +.coin-draws__tick { + fill: var(--sw-ink-2); + font-size: 11px; +} + +.coin-draws__region--fires, +.coin-draws__tick--threshold { + fill: var(--sw-accent); +} + +.coin-draws__band { + fill: var(--sw-accent-wash); +} + +.coin-draws__line { + stroke: var(--sw-ink-2); + stroke-width: 1.25; +} + +.coin-draws__threshold { + stroke: var(--sw-accent); + stroke-width: 1.5; +} + +.coin-draws__draw--fires { + fill: var(--sw-accent); +} + +.coin-draws__draw--waits { + fill: var(--sw-paper-2); + stroke: var(--sw-ink-2); + stroke-width: 1.5; +} + +/* Each step shows its own draw and readout for a quarter of the loop. */ +.coin-draws__step, +.coin-draws__draw { + animation: coin-draws-show calc(4 * var(--coin-step)) step-end infinite; + animation-delay: calc(var(--step) * var(--coin-step) - 4 * var(--coin-step)); +} + +/* Hidden just before the quarter ends, so that two steps never show together. */ +@keyframes coin-draws-show { + 0% { visibility: visible; } + 24.9% { visibility: hidden; } + 100% { visibility: hidden; } +} + +/* Without motion, the four draws show at once over a count of those that fire. */ +@media (prefers-reduced-motion: reduce) { + .coin-draws__step, + .coin-draws__draw { + animation: none; + } + + .coin-draws__step { + display: none; + } + + .coin-draws__summary { + display: inline; + } +} diff --git a/pocs/zeroth-playground/examples/queue/components/coin-draws.tsx b/pocs/zeroth-playground/examples/queue/components/coin-draws.tsx new file mode 100644 index 00000000..46d66fa7 --- /dev/null +++ b/pocs/zeroth-playground/examples/queue/components/coin-draws.tsx @@ -0,0 +1,73 @@ +import "./coin-draws.css"; + +/** Arrive's threshold at rate 2.5 over a step of 0.5: e^(-1.25). */ +const THRESHOLD = Math.exp(-2.5 * 0.5); + +/** Four draws of u_Arrive, one per step: three reach the threshold, close to the chance 0.71. */ +const DRAWS = [0.1, 0.71, 0.4, 0.88]; + +/** Where a value in [0, 1] sits along the axis, as a share of the drawing's width. */ +function across(value: number): string { + return `${8 + value * 84}%`; +} + +function verdict(u: number): "fires" | "waits" { + return u >= THRESHOLD ? "fires" : "waits"; +} + +/** + * Arrive's coin over four steps: each step one draw lands on [0, 1], and the + * transition fires when it reaches the threshold. Without motion, the four + * draws show at once. + */ +export const CoinDraws: React.FC = () => { + const fired = DRAWS.filter((u) => verdict(u) === "fires").length; + return ( + + ); +}; diff --git a/pocs/zeroth-playground/examples/queue/meta.ts b/pocs/zeroth-playground/examples/queue/meta.ts new file mode 100644 index 00000000..f548ffb6 --- /dev/null +++ b/pocs/zeroth-playground/examples/queue/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Rates as coins", + title: "Queue", + summary: "A rate becomes a draw tested against a threshold each step.", + rung: "rates", + order: 50, + listed: true, + options: { shape: "modular", dt: 0.5 }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/queue/net.pn.yaml b/pocs/zeroth-playground/examples/queue/net.pn.yaml new file mode 100644 index 00000000..2ec940fc --- /dev/null +++ b/pocs/zeroth-playground/examples/queue/net.pn.yaml @@ -0,0 +1,19 @@ +name: queue +kind: stochastic + +places: + Waiting: + capacity: 4 + Served: + +transitions: + Arrive: + outputs: + Waiting: + rate: 2.5 + Serve: + inputs: + Waiting: + outputs: + Served: + rate: 3 diff --git a/pocs/zeroth-playground/examples/queue/page.mdx b/pocs/zeroth-playground/examples/queue/page.mdx new file mode 100644 index 00000000..2d14e8ec --- /dev/null +++ b/pocs/zeroth-playground/examples/queue/page.mdx @@ -0,0 +1,21 @@ +import { CoinDraws } from "./components/coin-draws"; + +A transition at rate $\lambda$ fires within a step of length $\Delta t$ with probability $1 - e^{-\lambda \Delta t}$. The compiler turns that into a coin: an input draw `u_T` and the test `X(u_T) >= θ`, with $\theta = e^{-\lambda \Delta t}$ computed at compile time. + +Over a step of 0.5, `Arrive` at rate 2.5 gets $\theta = e^{-1.25} \approx 0.29$, so it fires in about 71% of steps: + +```python +return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) +``` + +The first term is the capacity of `Waiting`, four tokens. The draws are Reals, so the places are Reals too and the modules use the theory LRA: `Waiting = Var(REAL)`, `Waiting + 1.0`. + +
+ +
+ +### Options + +- **Time step** sets $\Delta t$, and every threshold follows it. +- **Marking** on Int keeps the places Int and moves each draw into an LRA module, `Draw_Arrive`, that writes a Bool `hit_Arrive`. The guard reads `X(hit_Arrive)` in place of the draw. +- **Rates** on clock refuses the net with `clocks-capacity`, because of the cap on `Waiting`. diff --git a/pocs/zeroth-playground/examples/sir-model/meta.ts b/pocs/zeroth-playground/examples/sir-model/meta.ts new file mode 100644 index 00000000..360ce7b0 --- /dev/null +++ b/pocs/zeroth-playground/examples/sir-model/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Weighted arcs", + title: "SIR model", + summary: "An output arc of weight 2: coins compile it, clocks refuse it.", + rung: "rates", + order: 80, + listed: true, + options: { shape: "modular" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/sir-model/net.pn.yaml b/pocs/zeroth-playground/examples/sir-model/net.pn.yaml new file mode 100644 index 00000000..d98dfaaf --- /dev/null +++ b/pocs/zeroth-playground/examples/sir-model/net.pn.yaml @@ -0,0 +1,28 @@ +name: sir_epidemic_model +description: 'An SIR epidemic with constant rates: infection makes a susceptible person infected, and recovery moves an infected person to recovered.' +kind: stochastic + +places: + Susceptible: + Infected: + Recovered: + +transitions: + Infection: + inputs: + Susceptible: + Infected: + outputs: + Infected: + weight: 2 + rate: 3 + Recovery: + inputs: + Infected: + outputs: + Recovered: + rate: 1 + +marking: + Susceptible: 9 + Infected: 1 diff --git a/pocs/zeroth-playground/examples/sir-model/page.mdx b/pocs/zeroth-playground/examples/sir-model/page.mdx new file mode 100644 index 00000000..fbe6bcb3 --- /dev/null +++ b/pocs/zeroth-playground/examples/sir-model/page.mdx @@ -0,0 +1,31 @@ +`Infection` takes one `Susceptible` and one `Infected` and gives back two `Infected`: an output arc of weight 2. Coins compile any weight. Clocks refuse it, because an SPN event moves one token per arc. + +In the monolithic shape, `Infection` takes its inputs at once, and its two tokens land at the end of the step: + +```python +Infected = ite(fire_Infection, Infected - 1.0, Infected) +Infected = ite(fire_Infection, Infected + 2.0, Infected) +``` + +In the modular shape, `Place_Infected` nets the two into one line: + +```python +Infected = ite(X(fire_Infection), Infected + 1.0, Infected) +``` + +The rates are constants, $\beta = 3$ and $\gamma = 1$. So `Infection` fires at rate 3 whatever the marking, where mass action would use $\beta \cdot S \cdot I$. + +### Options + +- **Rates** on clock refuses the net with `clocks-arc-weight`. +- **Shape** on monolithic gives the take and the landing above. + +### Open questions + + + Can SPN take a rate that reads the marking, such as $\beta \cdot S \cdot I$ for mass action? `Exp` takes a constant today. Under coins such a rate is code, which this playground does not parse. + + + + How should SPN express an arc of weight 2: `(n + 1) + 1`, an `Inc` of `Inc`, or a count on `Inc` and `Dec`? + diff --git a/pocs/zeroth-playground/package.json b/pocs/zeroth-playground/package.json new file mode 100644 index 00000000..00fe5601 --- /dev/null +++ b/pocs/zeroth-playground/package.json @@ -0,0 +1,54 @@ +{ + "name": "zeroth-playground", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "The Petri net IR to Zeroth reactive modules playground: one HTML file with both editors, a net preview and a page per example.", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "lint:tsc": "tsc --noEmit && tsc -p compiler --noEmit", + "screenshot": "node playground/scripts/screenshot.ts", + "check:zrth": "node --import ./compiler/scripts/resolve-ts.ts compiler/scripts/check-zrth.ts" + }, + "dependencies": { + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "elkjs": "0.12.0", + "js-yaml": "5.4.2", + "monaco-editor": "0.57.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-resizable-panels": "4.14.1", + "zod": "4.6.5" + }, + "devDependencies": { + "@mdx-js/rollup": "3.1.1", + "@types/mdx": "2.0.14", + "@types/node": "24.19.1", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.1", + "oxc-transform-react": "0.152.0", + "playwright": "1.63.0", + "rehype-mathjax": "7.1.0", + "remark-math": "6.0.0", + "typescript": "7.0.2", + "vite": "8.3.2", + "vite-plugin-singlefile": "2.3.3", + "vitest": "5.0.3" + }, + "pnpm": { + "peerDependencyRules": { + "allowedVersions": { + "@vitejs/plugin-react>oxc-transform-react": "0.152.0" + } + } + }, + "license": "(MIT OR Apache-2.0)" +} diff --git a/pocs/zeroth-playground/playground/README.md b/pocs/zeroth-playground/playground/README.md new file mode 100644 index 00000000..16488325 --- /dev/null +++ b/pocs/zeroth-playground/playground/README.md @@ -0,0 +1,127 @@ +# Playground + +The React app that shows the compiler at work: the IR editor, the Python it compiles to, a preview of the net, a documentation page per example, and the Compiler view. `pnpm build` writes it to one HTML file, `dist/index.html`, that opens from `file://`. + +## Folders + +One concept per file, kebab-case names, a file's private helpers in a folder of the same name, tests beside the file they test. + +The source sits in three folders at the root. `vite.config.ts` sets Vite's root to `playground/`, which imports the other two; the build still writes `dist/index.html` at the root. The playground imports only `compiler/index.ts` from the compiler, and the examples through `examples/catalog.ts` and their pages. + +| Folder | Holds | +| --- | --- | +| `app/` | the shell: `App`, the URL route, the open document and its edits, the header with the view switch and the example picker | +| `examples-view/` | the Examples view: its grid, the module view with its file list and diagnostics, the compiler options panel, hover state, module edits | +| `compiler-view/` | the Compiler view: the guide, the pipeline graph, the stage list, the stage cards and their live samples, the options table | +| `options/` | how the compiler options read and change in the playground, shared by both views | +| `ui/` | the primitives both views use: `Panel`, `Switch`, `useFolds`, the resize target | +| `editor/` | Monaco setup and theme, the `petri-net-ir` language and its completions, the provenance hover, line decorations, the React wrapper | +| `preview/` | the net graph, its elkjs layout, the SVG | +| `docs/` | the documentation view, the page registry, and the components it passes to every page: the `Figure` frame, `OpenQuestion`, and `CodeBlock` for fenced code | +| `pointer/` | the hover dwell shared by both views | +| `theme/` | the swiss variables and styles, fonts, the wordmark | +| `scripts/` | the screenshot script | + +`index.html` and `main.tsx` sit at the top of the folder. + +## Data flow + +``` +App state: Document { exampleId, irText, options, module edits } + │ compile(irText, { options }) + ▼ +Compilation { ir, options, irTrace, graph, files, errors, warnings } + ├─► IR editor and module editor (Monaco) + ├─► Compiler options panel (optionStates; withOption on a change) + ├─► Net preview (elkjs, SVG) + └─► Compiler view (one live sample per stage) + +Pointer over a line or node ─► useDwell ─► Hover { provenance } + ─► lit lines in both editors and the lit node in the preview +``` + +- `App` in `app/app.tsx` holds the document and compiles it during render. `app/route.ts` reads the URL hash it opens on, and `app/document.ts` holds the document's pure transitions. +- `ExamplesView` takes the document and hands back the next one, built with those transitions. `CompilerView` only reads it. +- The IR carries no options: the options panel holds them. +- A new IR text or new options drop the module edits. Reset and switching examples rebuild the document from the catalog. +- Both views stay mounted under React's `Activity`, so each keeps its panel sizes, collapsed panels and pins. +- The playground passes no code parser, so the nets with code strings are refused with `code-not-parsed`. + +## Layout + +- The Examples view is a `react-resizable-panels` grid: the documentation as a full-height first column, the IR and the module side by side, the net preview under them. Dragging a gridline resizes a row or a column, and dragging the point where two cross resizes both. A hovered or dragged gridline shows a blue overlay with a deep-blue line. +- Every view is the same `Panel`. The documentation, the compiler options, the preview and the files collapse from the chevron in their head; collapsed against a side, a panel becomes a strip with its title written upright. +- The example picker is a popover anchored to its button and walked with the arrow keys. It lists the listed examples rung by rung and skips an empty rung; an unlisted example appears while it is open. +- `NetPreview` is the one component that takes a `PetriNetIr`, the boundary a canvas can replace. + +## The compiler options panel + +`optionStates` and `OPTIONS` from the compiler decide the values, the defaults and why an option does not apply. Two files sit on top: + +| File | Decides | +| --- | --- | +| `options/option-labels.ts` | labels, one-line hints, value labels, sections, which option nests under which | +| `options/option-edits.ts` | `withOption`, which turns a control's text into the options the panel stores, and `sameOptions` | + +The panel stores only what `optionsForNet` keeps: the options that apply to the net and differ from the default. An option that does not apply is greyed, its control reads "not used", and the reason replaces its hint. Its default has no effect on the net, so the control does not show it. The Compiler view's option table in `compiler-view/strategy-table.tsx` reads the defaults and what each option applies to from `OPTIONS`. + +## The Compiler view + +`compiler-view/stages.ts` describes each stage of the compiler's pipeline to readers: its call, what goes in and comes out, and the facts worth knowing. Keep it in step with the pipeline in [compiler/README.md](../compiler/README.md#pipeline). To add a stage: + +1. Add the stage to `STAGES` in `compiler-view/stages.ts`. +2. Place it and its edges in `compiler-view/pipeline-layout.ts`. +3. Write its live sample in `compiler-view/stage-sample/` and route it from `stage-sample.ts`. +4. Cover it in `stage-sample.test.ts` and `pipeline-layout.test.ts`. + +## Conventions + +- Components are `export const Name: React.FC = (...) => ...`, and a component private to its file is the same `const` without `export`. Name the props type unless it is one short field, such as `{ text: string }`. Hooks and helpers are `function` declarations. +- React types need no import: write `React.FC`, `React.ReactNode`, `React.KeyboardEvent`. TypeScript reads `React` from the namespace `@types/react` declares, in type positions only. Keep `allowUmdGlobalAccess` off, so a React value used without an import stays an error. Import hooks and other values from `react` as usual. +- The React Compiler memoizes, through `@vitejs/plugin-react`'s `compiler` option with `oxc-transform-react`. Derive values in render and do not add `useMemo` or `useCallback`. +- Every React Compiler diagnostic fails `pnpm build`, the dev server's module and the tests that import it: a ref read in render, `setState` in render, an effect that only derives state, a construct the compiler cannot memoize. `vite.config.ts` sets `panicThreshold: "all_errors"` and the stricter checks. Restructure the component rather than opting out. `"use no memo"` still compiles the function, and a diagnostic inside it is only logged. +- Effects only synchronize systems React does not own. Today that is Monaco, in `editor/monaco-editor.tsx`. +- State is plain data with pure transition functions beside it, tested without the DOM. `app/document.ts`, `examples-view/module-edits.ts` and `pointer/pointer-motion.ts` are examples. +- Styles are plain CSS with the swiss variables in `theme/theme.css`. Use current CSS where it removes code: anchor positioning, `@starting-style`, `:has`, the Popover API. +- Comments say what the code does and why. Prose follows the house style: plain words, short sentences, no em dashes. + +## Bundle + +`pnpm build` inlines everything into `dist/index.html`: scripts, CSS, fonts and the Monaco editor worker. MDX maths is rendered to SVG at build time. Monaco and elkjs are most of the file. Measure the file before adding a dependency. + +A code parser for the IR's code strings would need the TypeScript compiler, which does not fit this budget. That is why nets with code strings are refused here. + +## Gates + +Run all three before each commit: + +```bash +pnpm lint:tsc +pnpm test +pnpm build +``` + +`pnpm lint:tsc` type-checks the project, then `compiler/` on its own, which fails on an import that leaves the folder. `pnpm build` also fails on any React Compiler diagnostic, as [Conventions](#conventions) says. + +## Tests + +- Tests sit beside their files as `*.test.ts`, and vitest runs the ones under `compiler/`, `examples/` and `playground/`. The compiler's tests are described in [compiler/README.md](../compiler/README.md#tests), the examples' in [examples/README.md](../examples/README.md#tests). +- Every test, in all three folders, reads as GIVEN, WHEN, THEN: a `// GIVEN` comment on the setup, `// WHEN` on the action, `// THEN` on the expectations. A test with no action goes from GIVEN to THEN. One THEN line sums up a group of `expect` calls. The test's name says the behaviour, such as "drops the texts on a new compilation and keeps the toggle". +- The playground's pure cores have their own tests: the route and the document, the option labels and edits, the IR completions, the net graph, the pipeline layout, the stage samples, module edits, pointer motion. +- `docs/example-pages.test.tsx` renders every example page with the components the documentation view passes, and `docs/code-block.test.tsx` checks that a fenced block renders one row per line. +- A test changed to make it pass is a changed contract. Say why in the commit message. + +## Checking the UI + +Check visible changes in a headless browser against the build: + +1. `pnpm build`. +2. Write a throwaway Playwright script as `playground/scripts/.tmp.ts` that opens `file://.../dist/index.html#`, acts, and screenshots. `#compiler`, or `#compiler/queue`, opens the Compiler view. +3. Run it with `node playground/scripts/.tmp.ts`, read the screenshots, then delete the script. Node 24 runs TypeScript as is, as `pnpm screenshot` does. + +Notes: + +- Playwright 1.63 drives its own Chromium 153. Run `pnpm exec playwright install chromium` once after `pnpm install`, and again after a Playwright upgrade; it downloads about 170 MB. +- Both views stay mounted, so filter selectors with `:visible`. +- Monaco renders spaces as non-breaking spaces in `innerText`; normalize before comparing text. +- `pnpm screenshot` captures the examples the picker lists and the Compiler view at 1440×900 into `screenshots/`. diff --git a/pocs/zeroth-playground/playground/app/app.tsx b/pocs/zeroth-playground/playground/app/app.tsx new file mode 100644 index 00000000..88230c0e --- /dev/null +++ b/pocs/zeroth-playground/playground/app/app.tsx @@ -0,0 +1,70 @@ +import { Activity, useState } from "react"; + +import { compile } from "../../compiler"; +import { FIRST_EXAMPLE, exampleById } from "../../examples/catalog"; +import { CompilerView } from "../compiler-view/compiler-view"; +import { ExamplesView } from "../examples-view/examples-view"; +import { useDwell } from "../pointer/use-dwell"; +import { type Document, documentOf, isChanged } from "./document"; +import { Header } from "./header"; +import { type View, routeOf } from "./route"; + +import type { Hover } from "../examples-view/hover"; + +/** + * The playground: the header over the two views. It holds the open document + * and compiles it once per render for both views; each view is hidden, not + * unmounted, while the other shows, so folds, sizes and pins survive the + * round trip. + */ +export const App: React.FC = () => { + const [route] = useState(() => routeOf(window.location.hash)); + const [view, setView] = useState(route.view); + const [document, setDocument] = useState(() => documentOf(route.example)); + // What the pointer has settled on: only a dwell lights anything. + const [hover, pointAt] = useDwell(null); + const compilation = compile(document.irText, { options: document.options }); + const example = exampleById(document.exampleId) ?? FIRST_EXAMPLE; + + function selectExample(id: string) { + const example = exampleById(id); + if (example !== undefined) { + setDocument(documentOf(example)); + pointAt(null); + } + } + + return ( +
+
selectExample(document.exampleId)} + /> + +
+ +
+
+ +
+ +
+
+
+ ); +}; diff --git a/pocs/zeroth-playground/playground/app/document.test.ts b/pocs/zeroth-playground/playground/app/document.test.ts new file mode 100644 index 00000000..8a8249ea --- /dev/null +++ b/pocs/zeroth-playground/playground/app/document.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { parsePetriNetIr } from "../../compiler"; +import { exampleById } from "../../examples/catalog"; +import { editFile, toggleEditing } from "../examples-view/module-edits"; +import { documentOf, isChanged, withIrText, withOptionText } from "./document"; + +import type { Example } from "../../examples/catalog"; + +function example(id: string): Example { + const found = exampleById(id); + if (found === undefined) { + throw new Error(`no example ${id}`); + } + return found; +} + +const queue = example("queue"); + +describe("the document", () => { + it("opens as the example, unchanged", () => { + // GIVEN the queue + // WHEN its document is opened + const document = documentOf(queue); + // THEN it holds the example's text and options, no edits, and counts as unchanged + expect(document).toEqual({ + exampleId: "queue", + irText: queue.ir, + options: queue.options, + module: { editing: false, texts: {} }, + }); + expect(isChanged(document, queue)).toBe(false); + }); + + it("drops the module edits on a new text, and keeps the document on the same text", () => { + // GIVEN the queue with a hand edit to net.py + const edited = { + ...documentOf(queue), + module: editFile(toggleEditing(documentOf(queue).module), "net.py", "# edited", "# compiled"), + }; + // WHEN the same text, then a new one, comes in + const same = withIrText(edited, queue.ir); + const changed = withIrText(edited, `${queue.ir}\n`); + // THEN the same text changes nothing; a new one drops the edits, keeps the toggle, and counts as a change + expect(same).toBe(edited); + expect(changed.module).toEqual({ editing: true, texts: {} }); + expect(isChanged(edited, queue)).toBe(true); + expect(isChanged(changed, queue)).toBe(true); + }); + + it("stores an option the panel changes, and counts the default back as unchanged", () => { + // GIVEN the queue, which opens modular + const parsed = parsePetriNetIr(queue.ir); + if (!parsed.ok) { + throw new Error("queue does not parse"); + } + const document = documentOf(queue); + // WHEN the shape goes to monolithic, then back to modular + const monolithic = withOptionText(document, parsed.ir, "shape", "monolithic"); + const back = withOptionText(monolithic, parsed.ir, "shape", "modular"); + // THEN the first is a change and the second is not + expect(isChanged(monolithic, queue)).toBe(true); + expect(isChanged(back, queue)).toBe(false); + }); +}); diff --git a/pocs/zeroth-playground/playground/app/document.ts b/pocs/zeroth-playground/playground/app/document.ts new file mode 100644 index 00000000..dbc137db --- /dev/null +++ b/pocs/zeroth-playground/playground/app/document.ts @@ -0,0 +1,58 @@ +import { type ModuleEdits, NO_EDITS, isEdited, recompiled } from "../examples-view/module-edits"; +import { sameOptions, withOption } from "../options/option-edits"; + +import type { CompilerOptions, OptionName, PetriNetIr } from "../../compiler"; +import type { Example } from "../../examples/catalog"; + +/** + * What the playground has open: the IR text and the compiler options beside + * it, since the text carries no options. The example it was loaded from + * names the page. The module edits ride along: a new IR text or new options + * compile a new module and drop them. + */ +export type Document = { + exampleId: string; + irText: string; + options: CompilerOptions; + module: ModuleEdits; +}; + +/** The example as it opens. */ +export function documentOf(example: Example): Document { + return { + exampleId: example.id, + irText: example.ir, + options: example.options, + module: NO_EDITS, + }; +} + +/** Whether the text, the options or the module differ from the example as it opens. */ +export function isChanged(document: Document, example: Example): boolean { + return ( + document.irText !== example.ir || + !sameOptions(document.options, example.options) || + isEdited(document.module) + ); +} + +/** The document with a new IR text, which drops the module edits; the same text keeps it as it is. */ +export function withIrText(document: Document, irText: string): Document { + return irText === document.irText + ? document + : { ...document, irText, module: recompiled(document.module) }; +} + +/** The document with one option changed from a control's text, which drops the module edits. */ +export function withOptionText( + document: Document, + ir: PetriNetIr, + name: OptionName, + text: string, +): Document { + return { + ...document, + options: withOption(ir, document.options, name, text), + module: recompiled(document.module), + }; +} diff --git a/pocs/zeroth-playground/playground/app/example-picker.tsx b/pocs/zeroth-playground/playground/app/example-picker.tsx new file mode 100644 index 00000000..208a23b1 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/example-picker.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; + +import { EXAMPLES, LADDER, exampleById } from "../../examples/catalog"; + +type ExamplePickerProps = { + exampleId: string; + onSelect: (id: string) => void; +}; + +const LIST_ID = "example-list"; + +/** The options of the open list, in order, for the arrow keys. */ +function optionsOf(list: HTMLElement): HTMLButtonElement[] { + return [...list.querySelectorAll('[role="option"]')]; +} + +/** + * Arrow keys walk the options; Home and End jump to the ends. Tab closes the + * list, which hands the focus back to the button, and moves on from there. + * Escape and outside clicks are the popover's. + */ +function walk(event: React.KeyboardEvent) { + if (event.key === "Tab") { + event.currentTarget.hidePopover(); + return; + } + const options = optionsOf(event.currentTarget); + const at = options.indexOf(document.activeElement as HTMLButtonElement); + const next = + event.key === "ArrowDown" + ? at + 1 + : event.key === "ArrowUp" + ? at - 1 + : event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : null; + if (next === null) { + return; + } + event.preventDefault(); + options[Math.max(0, Math.min(options.length - 1, next))]?.focus(); +} + +/** + * The example picker: a button naming the open example by what it tackles + * and the net it uses, and a popover listing the examples up the ladder, + * rung by rung, one line each. A check marks the open one. The footer names + * the net and the summary of the example under the pointer or the keyboard + * focus. Unlisted examples appear only while open. The popover is anchored + * under the button by CSS anchor positioning, and closes on a choice, + * Escape, Tab or a click outside. + */ +export const ExamplePicker: React.FC = ({ exampleId, onSelect }) => { + // The option holding the keyboard focus, the one Tab would reach. + const [focused, setFocused] = useState(null); + // The option the footer describes: the last one hovered or focused. + const [active, setActive] = useState(null); + const current = exampleById(exampleId); + const shown = EXAMPLES.filter((example) => example.listed || example.id === exampleId); + const about = exampleById(active ?? exampleId); + const tabStop = focused ?? exampleId; + + function toggle(event: React.ToggleEvent) { + if (event.newState === "open") { + // Opening the list puts the focus on the example already open. + event.currentTarget.querySelector('[aria-selected="true"]')?.focus(); + } else { + setFocused(null); + setActive(null); + } + } + + return ( + <> + +
setActive(focused)} + > + {LADDER.map((rung) => { + const examples = shown.filter((example) => example.rung === rung.id); + const titleId = `${LIST_ID}-${rung.id}`; + return examples.length === 0 ? null : ( +
+
+ {rung.title} +
+ {examples.map((example) => ( + + ))} +
+ ); + })} + +
+ + ); +}; diff --git a/pocs/zeroth-playground/playground/app/header.tsx b/pocs/zeroth-playground/playground/app/header.tsx new file mode 100644 index 00000000..0c340122 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/header.tsx @@ -0,0 +1,58 @@ +import { HashWordmark } from "../theme/hash-wordmark"; +import { ExamplePicker } from "./example-picker"; +import { ViewSwitch } from "./view-switch"; + +import type { View } from "./route"; + +type HeaderProps = { + view: View; + onSelectView: (view: View) => void; + exampleId: string; + /** The IR text, the options or the module differ from the example as it opens. */ + changed: boolean; + onSelectExample: (id: string) => void; + onReset: () => void; +}; + +/** A counter-clockwise arrow: back to how the example opens. */ +const ResetIcon: React.FC = () => { + return ( + + ); +}; + +export const Header: React.FC = ({ + view, + onSelectView, + exampleId, + changed, + onSelectExample, + onReset, +}) => { + return ( +
+ +

+ Petri net to Zeroth reactive modules +

+ +
+ {changed ? ( + + ) : null} + +
+
+ ); +}; diff --git a/pocs/zeroth-playground/playground/app/route.test.ts b/pocs/zeroth-playground/playground/app/route.test.ts new file mode 100644 index 00000000..81711b50 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/route.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { FIRST_EXAMPLE } from "../../examples/catalog"; +import { routeOf } from "./route"; + +describe("routeOf", () => { + it("opens the example a hash names in the Examples view", () => { + // GIVEN the hash of an example + // WHEN it is read + const route = routeOf("#queue"); + // THEN the Examples view opens on that example + expect(route.view).toBe("examples"); + expect(route.example.id).toBe("queue"); + }); + + it("opens the Compiler view, on the example after the slash", () => { + // GIVEN the Compiler view's hash, with and without an example + // WHEN each is read + const withExample = routeOf("#compiler/queue"); + const alone = routeOf("#compiler"); + // THEN both open the Compiler view, the bare one on the first example + expect(withExample).toEqual({ view: "compiler", example: expect.objectContaining({ id: "queue" }) }); + expect(alone).toEqual({ view: "compiler", example: FIRST_EXAMPLE }); + }); + + it("opens the first example for an empty hash or one the catalog does not hold", () => { + // GIVEN no hash, and a hash naming no example + // THEN each opens the first example in the Examples view + expect(routeOf("")).toEqual({ view: "examples", example: FIRST_EXAMPLE }); + expect(routeOf("#no-such-example")).toEqual({ view: "examples", example: FIRST_EXAMPLE }); + }); +}); diff --git a/pocs/zeroth-playground/playground/app/route.ts b/pocs/zeroth-playground/playground/app/route.ts new file mode 100644 index 00000000..327cee4b --- /dev/null +++ b/pocs/zeroth-playground/playground/app/route.ts @@ -0,0 +1,19 @@ +import { type Example, FIRST_EXAMPLE, exampleById } from "../../examples/catalog"; + +/** The two views: the examples workspace, or how the compiler works. */ +export type View = "examples" | "compiler"; + +/** What the playground opens on. */ +export type Route = { view: View; example: Example }; + +/** + * What a URL hash names, so a link opens the playground on it: `#queue` is + * an example, `#compiler` the Compiler view, `#compiler/queue` both. An + * example the catalog does not hold opens the first one. + */ +export function routeOf(hash: string): Route { + const [first = "", second = ""] = hash.replace(/^#/u, "").split("/"); + return first === "compiler" + ? { view: "compiler", example: exampleById(second) ?? FIRST_EXAMPLE } + : { view: "examples", example: exampleById(first) ?? FIRST_EXAMPLE }; +} diff --git a/pocs/zeroth-playground/playground/app/view-switch.tsx b/pocs/zeroth-playground/playground/app/view-switch.tsx new file mode 100644 index 00000000..38b56296 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/view-switch.tsx @@ -0,0 +1,30 @@ +import type { View } from "./route"; + +const VIEWS: readonly { id: View; label: string }[] = [ + { id: "examples", label: "Examples" }, + { id: "compiler", label: "Compiler" }, +]; + +type ViewSwitchProps = { + view: View; + onSelect: (view: View) => void; +}; + +/** The two views as a segmented control: the one shown is pressed. */ +export const ViewSwitch: React.FC = ({ view, onSelect }) => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx b/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx new file mode 100644 index 00000000..4199b26c --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx @@ -0,0 +1,59 @@ +import { OpenQuestion } from "../docs/open-question"; +import { DWELL_MS } from "../pointer/pointer-motion"; +import { StageRef } from "./stage-ref"; +import { StrategyTable } from "./strategy-table"; + +## The compiler + +This view shows how the playground turns the IR text into Python, one part at a time. The graph holds every part. Rounded boxes are documents, square boxes are steps, and dashed boxes are inputs beside the main line. A notch in a corner marks what the playground adds around the compiler. + +Hover a box, or a stage named in this guide, to open its card under the graph. Click to pin it, and click again to let it go. Beside each card, a Live section shows what the stage made of the example picked in the header. Pick another example in the header, or edit the text or the compiler options in the Examples view, and the cards follow. + +### The stages + +1. The IR text is the YAML in the IR editor of the Examples view. It carries no compiler options. +2. Parse reads it with js-yaml, then checks it against a strict zod schema. The result is a PetriNetIr, or diagnostics with their lines. +3. The compiler options come from the panel under the IR editor. The compile passes them beside the parsed document, to the lowering and the traces; the text never holds them. +4. Lower calls `lowerPetriNetIr(ir, options, parseCode)`. No code parser is passed, so a net with code strings is refused with `code-not-parsed`. +5. The module graph is linear, in LIA or LRA, under coins, and SPN under clocks. Under **Shape** on modular, it holds one module per transition and per place, composed. Under **Shape** on monolithic, it holds one module for the whole step. Under **Marking** on Int, one LRA `Draw_` module per rated transition turns its draw into a flag, and the LIA module that fires the transition reads it. +6. Emit prints the graph as Python over `zrth.sugar`, and writes each line with its provenance. The files come `net.py` first, each with its trace: one file, or one per module under **Files** on one per module. +7. Trace covers the IR text with line ranges. Each range has a provenance: what the lines are, why they are there, the IR path and the net item. With the files' own, these are the traces the editors use. +8. provenanceAt answers for one line with the innermost range that holds it. +9. The hover lights the matching ranges in the other views. Under the modular shape, a transition in the IR lights its variables, its module class and the line that makes its instance. Under **Shape** on monolithic, it lights its draw variable, if it has a rate, and the line of the step that sets its `fire_` flag. Under **Marking** on Int, it also lights its `Draw_` module: the class, its `hit_` variable and its instance. The diagnostics are placed on IR lines through the trace. + +For a trace $T$ of ranges $r$ from line $s_r$ to line $e_r$, the innermost range at line $\ell$ is the one with the fewest lines: + +$$ +r^{*}(\ell) = \text{the } r \in T \text{ with } s_r \le \ell \le e_r \text{ and } e_r - s_r \text{ least.} +$$ + +### Options + +Each option picks one strategy. The Compiler options panel holds them in four groups: Structure, Rates, Choices and Coloured tokens. The document carries none. An option sits under the one that usually decides whether it applies: **Files** under **Shape**, because it applies under modular (and also under clocks), and **Time step** and **Marking** under **Rates**, because they apply under coin (**Time step** also sets the step for dynamics). An option that does not apply to the net is greyed and reads "not used", with the reason in place of its hint. Its default would have no effect. + + + +The highlighted value is the compiler's default, from `OPTIONS`, the table that also gives what each option applies to. The examples open with their own options: the modular shape wherever it compiles the net, clocks for Fork under clocks, and monolithic for the coloured nets, which the modular shape refuses with `modular-coloured-not-lowered`. + +Coins test each rated transition once per **Time step** against a uniform draw, in LIA or LRA. Clocks arm an exponential clock per transition over continuous time, in SPN. So **Rates** on clock takes **Shape**, **Time step**, **Marking** and **Control** out of play. + +### Diagnostics + +Every diagnostic is `{ code, message, item }`. They come from three places. + +- Parse: `yaml-syntax` for text that is not YAML, `schema` for a document that does not fit the IR, and a code of its own for a reference that does not hold, such as `unknown-place` for an arc to an undeclared place or `kind-mismatch` for a `kind` the rates do not give. `yaml-syntax` sits at the line js-yaml reports; the others at the line of their key, when the key is in the text. +- The compiler options: `option-not-applicable`, a warning for each option asked for that does not apply to the net, with the reason the panel shows. The panel keeps only the options that apply, so it does not appear here. +- Lower: refusals by name, such as `clocks-capacity` or `modular-coloured-not-lowered`, and refusals of code, here always `code-not-parsed`. A net refused by a lowering's `refusals` has no code read, so its refusals never mix with `code-not-parsed`. + +A lowering diagnostic names an item, not a line. `compile` finds the line with `firstLineOf`: the start of the first range of the IR trace whose source is that item. A refused net keeps its IR trace, so a hover still pairs the IR with the net preview. + +### What the playground adds + +- **The Compiler options panel.** The options sit beside the text, not in it, each with a one-line hint. `optionStates` greys what does not apply and says why. Each example opens with its options from the catalog. +- **Cross-highlighting.** `matchingLines` pairs ranges by source, else when one IR path contains the other. A hover takes effect once the pointer has rested {DWELL_MS} ms, so a sweep lights nothing. + +### Open questions + + + A lowering diagnostic lands on the first IR line of its item, so a `code-not-parsed` on a kernel points at the transition, not at `kernel:`. Should a diagnostic carry the IR path it concerns? + diff --git a/pocs/zeroth-playground/playground/compiler-view/compiler-view.css b/pocs/zeroth-playground/playground/compiler-view/compiler-view.css new file mode 100644 index 00000000..98d9c350 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/compiler-view.css @@ -0,0 +1,752 @@ +/* + * The compiler view: the guide, the pipeline graph and the stage card. The + * swiss marks of the rest of the app: paper, ink, hairlines, one accent for + * what the focus lights, amber nowhere but the open questions. + */ + +/* -- Guide ----------------------------------------------------------------- */ + +.compiler-guide ol { + margin: 0 0 0.95rem; + padding-left: 1.35rem; +} + +.compiler-guide ol li { + margin: 0 0 0.45rem; + padding-left: 0.15rem; +} + +.compiler-guide ol li::marker { + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.75rem; +} + +/* A stage named in the prose: a dotted underline that turns blue with the stage in the graph. */ +.stage-ref { + padding: 0; + border: none; + border-bottom: 1px dotted var(--sw-ink-2); + background: none; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + transition: + color 160ms var(--sw-ease), + border-color 160ms var(--sw-ease), + background-color 160ms var(--sw-ease); +} + +.stage-ref:hover, +.stage-ref[data-focused="true"] { + border-bottom-color: var(--sw-accent); + background: var(--sw-accent-wash); + color: var(--sw-accent); +} + +.stage-ref[aria-pressed="true"] { + border-bottom-style: solid; + border-bottom-color: var(--sw-accent); +} + +.stage-ref:focus-visible { + outline: 1px solid var(--sw-accent); + outline-offset: 1px; +} + +.docs .strategy-table { + max-width: none; + font-size: 0.8rem; +} + +.docs .strategy-table th, +.docs .strategy-table td { + vertical-align: baseline; +} + +.docs .strategy-table thead th { + font-size: 0.62rem; +} + +.docs .strategy-table tbody th { + width: auto; + color: var(--sw-ink); + font-family: var(--sw-sans); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: normal; + white-space: nowrap; +} + +.docs .strategy-table td:nth-child(2) { + white-space: nowrap; +} + +.docs .strategy-table code + code { + margin-left: 0.3rem; +} + +.docs .strategy-table code[data-default="false"] { + background: none; + color: var(--sw-ink-2); +} + +/* -- Pipeline graph ---------------------------------------------------------- */ + +.pipeline { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.pipeline__legend { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + gap: 0.4rem 1.25rem; + margin: 0; + padding: 0.6rem 0.75rem 0; + list-style: none; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.pipeline__legend li { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.pipeline__legend li::before { + content: ""; + display: block; + width: 0.95rem; + height: 0.6rem; + border: 1px solid var(--sw-ink); +} + +.pipeline__legend li[data-kind="data"]::before { + border-color: var(--sw-ink-2); + border-radius: 0.3rem; + background: var(--sw-paper); +} + +.pipeline__legend li[data-kind="step"]::before { + background: var(--sw-paper-2); +} + +.pipeline__legend li[data-kind="input"]::before { + border-style: dashed; + border-color: var(--sw-ink-2); +} + +.pipeline__legend li[data-kind="playground"]::before { + width: 0.5rem; + height: 0.5rem; + border: none; + background: linear-gradient(135deg, var(--sw-ink-2) 50%, transparent 50%); +} + +.pipeline__legend li[data-kind="refused"]::before { + border-color: var(--sw-red); + border-radius: 0.3rem; +} + +.pipeline__legend li[data-kind="idle"]::before { + border-color: var(--sw-ink-2); + border-radius: 0.3rem; + opacity: 0.4; +} + +.pipeline__svg { + display: block; + flex: 1 1 auto; + width: 100%; + min-height: 0; + padding: 0.25rem 0.75rem 0.5rem; + overflow: visible; +} + +.pipeline__node { + cursor: pointer; + outline: none; + transform-box: fill-box; + transform-origin: center; + transition: + opacity 200ms var(--sw-ease), + transform 240ms var(--sw-ease); +} + +.pipeline__shape { + fill: var(--sw-paper-2); + stroke: var(--sw-ink); + stroke-width: 1.25; + transition: + fill 200ms var(--sw-ease), + stroke 200ms var(--sw-ease), + stroke-width 200ms var(--sw-ease); +} + +.pipeline__node[data-kind="data"] .pipeline__shape { + fill: var(--sw-paper); + stroke: var(--sw-ink-2); + stroke-width: 1; +} + +.pipeline__node[data-kind="input"] .pipeline__shape { + fill: none; + stroke: var(--sw-ink-2); + stroke-width: 1; + stroke-dasharray: 4 3; +} + +.pipeline__notch { + fill: var(--sw-ink-2); + transition: fill 200ms var(--sw-ease); +} + +.pipeline__label { + fill: var(--sw-ink); + font-family: var(--sw-sans); + font-size: 12.5px; + font-weight: 600; + letter-spacing: -0.01em; + transition: fill 200ms var(--sw-ease); +} + +.pipeline__node[data-kind="step"] .pipeline__label, +.pipeline__node[data-kind="input"] .pipeline__label { + font-family: var(--sw-mono); + font-size: 11.5px; + font-weight: 500; + letter-spacing: 0; +} + +.pipeline__note { + fill: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 9px; + letter-spacing: 0.02em; + transition: fill 200ms var(--sw-ease); +} + +.pipeline__node[data-stage="code-parser"] .pipeline__label { + fill: var(--sw-ink-2); +} + +/* How the stage fared on the example: refused in red, not reached faded. */ +.pipeline__node[data-tone="refused"] .pipeline__shape { + stroke: var(--sw-red); +} + +.pipeline__node[data-tone="refused"] .pipeline__label { + fill: var(--sw-red); +} + +.pipeline__node[data-tone="idle"]:is([data-state="rest"], [data-state="near"]) { + opacity: 0.45; +} + +/* The pin pops in when a click pins the stage. */ +.pipeline__pin { + fill: var(--sw-accent); + transition: r 220ms var(--sw-ease); + + @starting-style { + r: 0; + } +} + +/* Focus: the stage in the accent and lifted a little, its neighbours as they are, everything else stepped back. */ +.pipeline__node[data-state="focus"] { + transform: scale(1.015); +} + +.pipeline__node[data-state="focus"] .pipeline__shape { + fill: var(--sw-accent-wash); + stroke: var(--sw-accent); + stroke-width: 2; +} + +.pipeline__node[data-state="focus"] .pipeline__label, +.pipeline__node[data-state="focus"] .pipeline__note { + fill: var(--sw-accent); +} + +.pipeline__node[data-state="focus"] .pipeline__notch { + fill: var(--sw-accent); +} + +.pipeline__node[data-state="dim"] { + opacity: 0.3; +} + +.pipeline__node:focus-visible .pipeline__shape { + stroke-width: 2.5; +} + +.pipeline__halo { + stroke: var(--sw-paper-2); + stroke-width: 6; +} + +.pipeline__line { + stroke: var(--sw-ink-2); + stroke-width: 1.1; + transition: + stroke 200ms var(--sw-ease), + stroke-width 200ms var(--sw-ease); +} + +.pipeline__edge[data-absent="true"] .pipeline__line { + stroke-dasharray: 3 3; +} + +.pipeline__edge { + transition: opacity 200ms var(--sw-ease); +} + +.pipeline__edge[data-state="focus"] .pipeline__line { + stroke: var(--sw-accent); + stroke-width: 1.75; +} + +.pipeline__edge[data-state="dim"] { + opacity: 0.22; +} + +.pipeline__arrow { + fill: var(--sw-ink-2); +} + +.pipeline__arrow--lit { + fill: var(--sw-accent); +} + +.pipeline__edge-label { + fill: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 9px; + letter-spacing: 0.02em; + paint-order: stroke fill; + stroke: var(--sw-paper-2); + stroke-width: 3px; + transition: fill 200ms var(--sw-ease); +} + +.pipeline__edge[data-state="focus"] .pipeline__edge-label { + fill: var(--sw-accent); +} + +@media (prefers-reduced-motion: reduce) { + .pipeline__node[data-state="focus"] { + transform: none; + } + + .pipeline__pin { + transition: none; + } +} + +/* -- Stage card --------------------------------------------------------------- */ + +.stage-card, +.stage-overview { + height: 100%; + overflow-y: auto; + user-select: text; + -webkit-user-select: text; +} + +.stage-card { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); + align-content: start; + gap: 1.25rem 2.25rem; + padding: 1rem 1.35rem 1.5rem; +} + +/* A pinned card leads with a way back, like a breadcrumb: "All stages", a hairline, then the stage's kind. */ +.stage-card__head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.45rem; +} + +.stage-card__head .kicker { + margin: 0; +} + +.stage-card__back + .kicker { + padding-left: 0.6rem; + border-left: 1px solid var(--sw-hair); +} + +/* + * The padding gives the focus wash room. The negative margins take it back, + * so the chevron sits on the card's left edge and a pinned head is as tall + * as an unpinned one. + */ +.stage-card__back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: -0.2rem 0 -0.2rem -0.4rem; + padding: 0.2rem 0.45rem 0.2rem 0.4rem; + border: none; + border-radius: 0.4rem; + background: none; + color: var(--sw-accent); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; + white-space: nowrap; + cursor: pointer; + transition: + color 160ms var(--sw-ease), + background-color 160ms var(--sw-ease); +} + +/* Two borders of a box turned an eighth draw the chevron, pointing back. */ +.stage-card__back-chevron { + width: 0.36rem; + height: 0.36rem; + border-bottom: 1.5px solid currentColor; + border-left: 1.5px solid currentColor; + rotate: 45deg; + translate: 0.05rem 0; + transition: translate 160ms var(--sw-ease); +} + +.stage-card__back:hover { + color: var(--sw-accent-deep); +} + +.stage-card__back:hover .stage-card__back-chevron { + translate: -0.1rem 0; +} + +.stage-card__back:focus-visible { + outline: none; + background: var(--sw-accent-wash); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--sw-accent) 35%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .stage-card__back, + .stage-card__back-chevron { + transition: none; + } +} + +.stage-card__doc { + max-width: 60ch; +} + +.stage-card__title { + margin: 0 0 0.4rem; + color: var(--sw-ink); + font-size: 1.15rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.25; +} + +.stage-card__summary { + margin: 0 0 0.8rem; + color: #22262c; + font-size: 0.85rem; + line-height: 1.6; +} + +.stage-card__call { + margin: 0 0 0.8rem; + padding: 0.45rem 0.6rem; + border-left: 2px solid var(--sw-ink); + background: var(--sw-paper); + color: var(--sw-ink); + font-family: var(--sw-mono); + font-size: 0.74rem; + font-variant-ligatures: none; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.stage-card__io { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr); + gap: 0.25rem 0.5rem; + margin: 0 0 0.8rem; + font-size: 0.8rem; + line-height: 1.5; +} + +.stage-card__io dt { + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.62rem; + letter-spacing: 0.08em; + line-height: 1.95; + text-transform: uppercase; +} + +.stage-card__io dd { + margin: 0; +} + +.stage-card__details { + margin: 0; + padding: 0.6rem 0 0 1.05rem; + border-top: 1px solid var(--sw-hair-2); + color: #22262c; + font-size: 0.82rem; + line-height: 1.58; +} + +.stage-card__details li { + margin: 0 0 0.3rem; +} + +.stage-card__details li::marker { + color: var(--sw-ink-2); +} + +.stage-card code { + padding: 0.05em 0.3em; + border-radius: 2px; + background: var(--sw-paper); + color: var(--sw-ink); + font-family: var(--sw-mono); + font-size: 0.82em; + font-variant-ligatures: none; + white-space: nowrap; +} + +.stage-card strong { + color: var(--sw-ink); + font-weight: 600; +} + +/* -- Live sample: what the stage made of the example ---------------------------- */ + +.sample { + align-self: start; + min-width: 0; + padding-left: 1.25rem; + border-left: 1px solid var(--sw-hair-2); +} + +.sample__kicker { + margin: 0 0 0.45rem; +} + +.sample__headline { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 0 0 0.75rem; + color: var(--sw-ink); + font-size: 0.85rem; + font-weight: 500; + line-height: 1.5; +} + +.sample__dot { + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--sw-ink-2); + transform: translateY(-0.05rem); +} + +.sample[data-tone="ok"] .sample__dot { + background: var(--sw-accent); +} + +.sample[data-tone="refused"] .sample__dot { + background: var(--sw-red); +} + +.sample[data-tone="idle"] .sample__dot { + border: 1px solid var(--sw-ink-2); + background: none; +} + +.sample[data-tone="idle"] .sample__headline { + color: var(--sw-ink-2); +} + +.sample__rows { + display: grid; + grid-template-columns: minmax(4.5rem, max-content) minmax(0, 1fr); + margin: 0 0 0.85rem; + font-size: 0.78rem; + line-height: 1.45; +} + +.sample__row { + display: contents; +} + +.sample__row dt, +.sample__row dd { + padding: 0.28rem 0; + border-top: 1px solid var(--sw-hair-2); +} + +.sample__row dt { + padding-right: 0.9rem; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.7rem; + white-space: nowrap; +} + +.sample__row dd { + margin: 0; + color: var(--sw-ink); + font-family: var(--sw-mono); + font-size: 0.72rem; + overflow-wrap: anywhere; +} + +.sample__row[data-muted="true"] dt, +.sample__row[data-muted="true"] dd { + color: var(--sw-ink-2); + opacity: 0.6; +} + +.sample__row[data-error="true"] dd { + color: var(--sw-red); +} + +.sample__excerpt { + margin: 0; +} + +.sample__source { + margin-bottom: 0.3rem; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.62rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sample__excerpt pre { + margin: 0; + padding: 0.4rem 0; + border: 1px solid var(--sw-hair-2); + background: var(--sw-paper-2); + font-family: var(--sw-mono); + font-size: 0.7rem; + font-variant-ligatures: none; + line-height: 1.6; + overflow-x: auto; +} + +.sample__line { + display: block; + padding-right: 0.75rem; + white-space: pre; +} + +.sample__line[data-lit="true"] { + background: var(--sw-accent-wash); +} + +.sample__number { + display: inline-block; + width: 2.6rem; + padding-right: 0.8rem; + color: var(--sw-ink-2); + text-align: right; + opacity: 0.7; +} + +/* -- Overview: every stage, one line each ---------------------------------------- */ + +.stage-overview { + padding: 1rem 1.35rem 1.5rem; +} + +.stage-overview__intro { + max-width: 60ch; +} + +.stage-overview__intro .kicker { + margin: 0 0 0.45rem; +} + +.stage-overview__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr)); + column-gap: 2.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.stage-overview__row { + display: grid; + grid-template-columns: 1.6rem 9.5rem minmax(0, 1fr); + align-items: baseline; + gap: 0.5rem; + width: 100%; + padding: 0.38rem 0; + border: none; + border-top: 1px solid var(--sw-hair-2); + background: none; + color: var(--sw-ink); + font: inherit; + font-size: 0.8rem; + line-height: 1.45; + text-align: left; + cursor: pointer; +} + +.stage-overview__row:hover .stage-overview__name { + color: var(--sw-accent); +} + +.stage-overview__row:focus-visible { + outline: 1px solid var(--sw-accent); + outline-offset: -1px; +} + +.stage-overview__number { + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.66rem; +} + +.stage-overview__name { + font-weight: 600; + transition: color 160ms var(--sw-ease); +} + +.stage-overview__headline { + color: #2b2f36; +} + +.stage-overview__row[data-tone="refused"] .stage-overview__headline { + color: var(--sw-red); +} + +.stage-overview__row[data-tone="idle"] .stage-overview__headline { + color: var(--sw-ink-2); +} + +/* Lines quoted out of order: a hairline marks where the numbers skip. */ +.sample__line[data-skipped="true"] { + border-top: 1px dashed var(--sw-hair); +} diff --git a/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx b/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx new file mode 100644 index 00000000..0eb093f0 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx @@ -0,0 +1,102 @@ +import "./compiler-view.css"; + +import { useId, useState } from "react"; +import { Group, Panel as ResizablePanel, Separator, usePanelRef } from "react-resizable-panels"; + +import { useDwell } from "../pointer/use-dwell"; +import { Panel } from "../ui/panel"; +import { RESIZE_TARGET } from "../ui/resize-target"; +import { useFolds } from "../ui/use-folds"; +import CompilerGuide from "./compiler-guide.mdx"; +import { focusHandoff } from "./focus-handoff"; +import { PipelineGraph } from "./pipeline-graph"; +import { StageCard } from "./stage-card"; +import { StageFocusContext } from "./stage-focus"; +import { StageOverview } from "./stage-overview"; +import { stageSamples } from "./stage-sample"; + +import type { StageFocus } from "./stage-focus"; +import type { SampleInput } from "./stage-sample"; +import type { StageId } from "./stages"; + +/** The one panel of the view that folds. */ +const FOLDS = ["guide"] as const; + +/** + * How the compiler works: the guide on the left, and on the right the graph + * of every stage over the card of the focused one, read off the example in + * the editor. A hover focuses a stage once the pointer settles on it, as in + * the examples view; a keyboard focus focuses it at once, and a click pins + * it. The card follows the hovered stage, else the keyboard's, else the + * pinned one, else lists them all. Every stage's sample is read once per render, + * off the example: its title, its IR text, its options and what it compiles to. + */ +export const CompilerView: React.FC = (input) => { + const [hovered, pointAt] = useDwell(null); + const [keyboard, setKeyboard] = useState(null); + const [pinned, setPinned] = useState(null); + const guideRef = usePanelRef(); + const folds = useFolds(FOLDS, () => guideRef.current); + const handoff = focusHandoff(useId()); + const focused = hovered ?? keyboard ?? pinned; + const samples = stageSamples(input); + const card = samples.find(({ stage }) => stage.id === focused); + const focus: StageFocus = { + focused, + pinned, + hover: pointAt, + focusKeyboard: setKeyboard, + togglePin: (stage) => setPinned(pinned === stage ? null : stage), + }; + + return ( + + + + +
+ +
+
+
+ + + + + + + + + + + + {card === undefined ? ( + + ) : ( + + )} + + + + +
+
+ ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/focus-handoff.ts b/pocs/zeroth-playground/playground/compiler-view/focus-handoff.ts new file mode 100644 index 00000000..b8180fb9 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/focus-handoff.ts @@ -0,0 +1,22 @@ +import type { StageId } from "./stages"; + +/** + * The ids the stage overview and the stage card pass the keyboard focus + * between. A click on an overview row pins its stage, and the card that + * replaces the overview takes the focus on its "All stages" link. That link + * unpins, and the overview that comes back puts the focus on the stage's row. + */ +export type FocusHandoff = { + back: string; + row: (stage: StageId) => string; +}; + +/** The handoff's ids under one prefix, unique to the view that renders both. */ +export function focusHandoff(prefix: string): FocusHandoff { + return { back: `${prefix}back`, row: (stage) => `${prefix}row-${stage}` }; +} + +/** Moves the keyboard focus to the element with the id, once React has rendered it. */ +export function focusById(id: string): void { + document.getElementById(id)?.focus(); +} diff --git a/pocs/zeroth-playground/playground/compiler-view/pipeline-graph.tsx b/pocs/zeroth-playground/playground/compiler-view/pipeline-graph.tsx new file mode 100644 index 00000000..b7e62b68 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/pipeline-graph.tsx @@ -0,0 +1,88 @@ +import { useId } from "react"; + +import { PipelineEdge } from "./pipeline-graph/pipeline-edge"; +import { PipelineNode } from "./pipeline-graph/pipeline-node"; +import { PIPELINE_EDGES, PIPELINE_VIEWBOX, edgeFocus, edgeLine, nodeBox } from "./pipeline-layout"; +import { useStageFocus } from "./stage-focus"; + +import type { StageWithSample } from "./stage-sample"; + +type PipelineGraphProps = { + /** Every stage in pipeline order, with how it fared on the example. */ + samples: readonly StageWithSample[]; +}; + +/** + * Every part of the compilation as one graph: documents, steps and side + * inputs joined by arrows, laid out by hand and scaled to the panel. A + * hover focuses a stage, lighting it and its edges and dimming the rest; a + * click pins it. Each stage also shows how it fared on the example: the + * one that refused it in red, the ones it never reached faded. + */ +export const PipelineGraph: React.FC = ({ samples }) => { + const { focused } = useStageFocus(); + const id = useId(); + const markers = { rest: `${id}-arrow`, lit: `${id}-arrow-lit` }; + const { x, y, width, height } = PIPELINE_VIEWBOX; + + return ( +
+
    +
  • document
  • +
  • step
  • +
  • side input
  • +
  • added by the playground
  • +
  • refused
  • +
  • not reached
  • +
+ + + {[ + { markerId: markers.rest, className: "pipeline__arrow" }, + { markerId: markers.lit, className: "pipeline__arrow pipeline__arrow--lit" }, + ].map(({ markerId, className }) => ( + + + + ))} + + {PIPELINE_EDGES.map((edge) => ( + + ))} + {samples.map(({ stage, sample }) => ( + + ))} + +
+ ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-edge.tsx b/pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-edge.tsx new file mode 100644 index 00000000..a55f44ba --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-edge.tsx @@ -0,0 +1,47 @@ +import { edgeLabel } from "../pipeline-layout"; + +import type { EdgeLine, FocusState, LabelSide } from "../pipeline-layout"; + +type PipelineEdgeProps = { + line: EdgeLine; + state: FocusState; + label?: string; + /** Where along the line the label sits, 0 to 1. */ + labelAt?: number; + labelSide?: LabelSide; + absent?: boolean; + /** The ids of the two arrowhead markers, at rest and lit. */ + markers: { rest: string; lit: string }; +}; + +/** + * One edge: a paper-coloured halo under the line, so a later edge crossing + * an earlier one cuts a small gap in it, then the line with its arrowhead + * and an optional label beside its middle. + */ +export const PipelineEdge: React.FC = ({ + line, + state, + label, + labelAt, + labelSide, + absent = false, + markers, +}) => { + const placed = label === undefined ? null : edgeLabel(line, labelAt, labelSide); + return ( + + + + {placed === null ? null : ( + + {label} + + )} + + ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-node.tsx b/pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-node.tsx new file mode 100644 index 00000000..0eade7c0 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-node.tsx @@ -0,0 +1,76 @@ +import { nodeFocus } from "../pipeline-layout"; +import { useStageTarget } from "../stage-focus"; +import { plainText } from "../rich-text"; + +import type { NodeBox } from "../pipeline-layout"; +import type { SampleTone } from "../stage-sample"; +import type { Stage } from "../stages"; + +type PipelineNodeProps = { + stage: Stage; + box: NodeBox; + /** How the stage fared on the example: a refusing stage is marked, one not reached steps back. */ + tone: SampleTone; +}; + +/** Where the name and the note sit, from the centre: a document is taller. */ +const TEXT_OFFSETS = { data: [-3.5, 12], step: [-2.5, 10.5], input: [-3, 11] } as const; + +/** + * One stage as a box: a document rounded on a grey ground, a step square + * with its name in mono, a side input dashed. A corner notch marks what the + * playground adds; a dot marks the pinned stage. A stage that refused the + * example is drawn in red, one the example never reached steps back. + * Focusable: Enter or Space pins, Escape unpins. + */ +export const PipelineNode: React.FC = ({ stage, box, tone }) => { + const { focused, pinned, togglePin, handlers } = useStageTarget(stage.id); + const isPinned = pinned === stage.id; + const left = box.x - box.width / 2; + const top = box.y - box.height / 2; + const [labelDy, noteDy] = TEXT_OFFSETS[stage.kind]; + return ( + { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + togglePin(stage.id); + } else if (event.key === "Escape" && pinned !== null) { + togglePin(pinned); + } + }} + > + {plainText(stage.summary)} + + {stage.owner === "playground" ? ( + + ) : null} + {isPinned ? : null} + + {stage.label} + + + {stage.note} + + + ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/pipeline-layout.test.ts b/pocs/zeroth-playground/playground/compiler-view/pipeline-layout.test.ts new file mode 100644 index 00000000..7243508a --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/pipeline-layout.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; + +import { PIPELINE_EDGES, PIPELINE_VIEWBOX, edgeFocus, edgeLabel, edgeLine, nodeBox, nodeFocus } from "./pipeline-layout"; +import { STAGES } from "./stages"; + +describe("the pipeline layout", () => { + it("keeps every node inside the view box, no two overlapping", () => { + // GIVEN every stage + // WHEN each is given its box + const boxes = STAGES.map((stage) => nodeBox(stage.id)); + // THEN each box lies inside the view box, and no two boxes overlap + for (const box of boxes) { + expect(box.x - box.width / 2).toBeGreaterThanOrEqual(PIPELINE_VIEWBOX.x); + expect(box.x + box.width / 2).toBeLessThanOrEqual(PIPELINE_VIEWBOX.x + PIPELINE_VIEWBOX.width); + expect(box.y - box.height / 2).toBeGreaterThanOrEqual(PIPELINE_VIEWBOX.y); + expect(box.y + box.height / 2).toBeLessThanOrEqual(PIPELINE_VIEWBOX.y + PIPELINE_VIEWBOX.height); + } + boxes.forEach((a, i) => + boxes.slice(i + 1).forEach((b) => { + const apart = + Math.abs(a.x - b.x) >= (a.width + b.width) / 2 || Math.abs(a.y - b.y) >= (a.height + b.height) / 2; + expect(apart).toBe(true); + }), + ); + }); + + it("joins every stage to the graph by at least one edge", () => { + // GIVEN every stage + // THEN some edge starts or ends at it + for (const stage of STAGES) { + expect(PIPELINE_EDGES.some((edge) => edge.from === stage.id || edge.to === stage.id), stage.id).toBe(true); + } + }); + + it("draws an edge from the side of one box to the side of the other", () => { + // GIVEN the lowering and the module graph, side by side + const lower = nodeBox("lower"); + const graph = nodeBox("graph"); + // WHEN the edge between them is drawn + const line = edgeLine(lower, graph); + // THEN it leaves the right side of the first at mid-height and stops short of the left side of the second + expect(line.y1).toBe(lower.y); + expect(line.x1).toBe(lower.x + lower.width / 2 + 1); + expect(line.x2).toBe(graph.x - graph.width / 2 - 3); + }); +}); + +describe("edgeLabel", () => { + it("sets a label above a horizontal line, beside a vertical one, off the upper side of a diagonal", () => { + // GIVEN lines in each direction + const rightward = { x1: 0, y1: 0, x2: 100, y2: 0 }; + const downward = { x1: 0, y1: 0, x2: 0, y2: 100 }; + const upward = { x1: 0, y1: 0, x2: 0, y2: -100 }; + const downRight = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const downLeft = { x1: 100, y1: 0, x2: 0, y2: 100 }; + // WHEN a label is set halfway along each + // THEN it sits centred above a horizontal line, starts right of a vertical one and stands + // off the upper side of a diagonal; "below" moves it to the other side + expect(edgeLabel(rightward, 0.5)).toEqual({ x: 50, y: -6, anchor: "middle" }); + expect(edgeLabel(downward, 0.5)).toEqual({ x: 6, y: 53, anchor: "start" }); + expect(edgeLabel(upward, 0.5).anchor).toBe("start"); + expect(edgeLabel(downRight, 0.5).anchor).toBe("start"); + expect(edgeLabel(downLeft, 0.5).anchor).toBe("end"); + expect(edgeLabel(downLeft, 0.5, "below").anchor).toBe("start"); + expect(edgeLabel(rightward, 0.5, "below")).toEqual({ x: 50, y: 9, anchor: "middle" }); + }); +}); + +describe("focus", () => { + it("leaves every node and edge at rest while no stage has the focus", () => { + // GIVEN no focused stage + // THEN a node and an edge both rest + expect(nodeFocus("lower", null)).toBe("rest"); + expect(edgeFocus({ from: "ir", to: "lower" }, null)).toBe("rest"); + }); + + it("lights the focused stage and its edges, keeps its neighbours, dims the rest", () => { + // GIVEN the focus on the lowering + const focused = "lower"; + // THEN the lowering and an edge into it are lit, the stages one edge away stay near, and a + // stage or an edge further off is dimmed + expect(nodeFocus("lower", focused)).toBe("focus"); + expect(nodeFocus("options", focused)).toBe("near"); + expect(nodeFocus("code-parser", focused)).toBe("near"); + expect(nodeFocus("diagnostics", focused)).toBe("near"); + expect(nodeFocus("hover", focused)).toBe("dim"); + expect(edgeFocus({ from: "ir", to: "lower" }, focused)).toBe("focus"); + expect(edgeFocus({ from: "emit", to: "files" }, focused)).toBe("dim"); + }); +}); diff --git a/pocs/zeroth-playground/playground/compiler-view/pipeline-layout.ts b/pocs/zeroth-playground/playground/compiler-view/pipeline-layout.ts new file mode 100644 index 00000000..f999525a --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/pipeline-layout.ts @@ -0,0 +1,169 @@ +import { stageById } from "./stages"; + +import type { StageId, StageKind } from "./stages"; + +/** + * Where the stages sit in the pipeline graph and how they connect, in the + * SVG's own units. Three bands: the side inputs over the lowering, the + * compile from text to Python, and under it the read-back from the traces + * to the hover, with the diagnostics at its start. A focus on one stage + * lights it and its edges, keeps its neighbours and dims the rest. + */ + +export const PIPELINE_VIEWBOX = { x: 0, y: 18, width: 1040, height: 300 }; + +const SIZES: Record = { + data: { width: 120, height: 46 }, + step: { width: 110, height: 36 }, + input: { width: 112, height: 40 }, +}; + +const CENTRES: Record = { + "code-parser": { x: 520, y: 50 }, + options: { x: 745, y: 50 }, + "ir-text": { x: 70, y: 150 }, + parse: { x: 220, y: 150 }, + ir: { x: 370, y: 150 }, + lower: { x: 520, y: 150 }, + graph: { x: 670, y: 150 }, + emit: { x: 820, y: 150 }, + files: { x: 970, y: 150 }, + diagnostics: { x: 220, y: 275 }, + trace: { x: 370, y: 275 }, + traces: { x: 600, y: 275 }, + provenance: { x: 785, y: 275 }, + hover: { x: 970, y: 275 }, +}; + +export type NodeBox = { + /** The centre. */ + x: number; + y: number; + width: number; + height: number; +}; + +export type LabelSide = "above" | "below"; + +export type PipelineEdge = { + from: StageId; + to: StageId; + /** A few words on what passes along it. */ + label?: string; + /** Where along the edge the label sits, from 0 at the start to 1 at the end; the middle by default. */ + labelAt?: number; + /** Which side of the line the label stands on: above (right of a vertical line) by default. */ + labelSide?: LabelSide; + /** For an input that is not there. */ + absent?: boolean; +}; + +export const PIPELINE_EDGES: readonly PipelineEdge[] = [ + { from: "ir-text", to: "parse" }, + { from: "parse", to: "ir" }, + { from: "ir", to: "lower" }, + { from: "code-parser", to: "lower", absent: true }, + { from: "options", to: "lower" }, + { from: "options", to: "emit", label: "files" }, + { from: "lower", to: "graph" }, + { from: "graph", to: "emit" }, + { from: "emit", to: "files" }, + { from: "ir", to: "trace" }, + { from: "trace", to: "traces" }, + { from: "files", to: "traces", label: "trace" }, + { from: "traces", to: "provenance" }, + { from: "provenance", to: "hover" }, + { from: "parse", to: "diagnostics", label: "errors" }, + { from: "lower", to: "diagnostics", label: "errors", labelAt: 0.3, labelSide: "below" }, + { from: "trace", to: "diagnostics", label: "line" }, +]; + +export function nodeBox(id: StageId): NodeBox { + return { ...CENTRES[id], ...SIZES[stageById(id).kind] }; +} + +/** Where the line from the box's centre toward `toward` leaves the box, `gap` units further out. */ +function boundaryPoint(box: NodeBox, toward: { x: number; y: number }, gap: number) { + const dx = toward.x - box.x; + const dy = toward.y - box.y; + const length = Math.hypot(dx, dy); + if (length === 0) { + return { x: box.x, y: box.y }; + } + const scale = Math.min( + dx === 0 ? Number.POSITIVE_INFINITY : box.width / 2 / Math.abs(dx), + dy === 0 ? Number.POSITIVE_INFINITY : box.height / 2 / Math.abs(dy), + ); + return { + x: box.x + dx * scale + (dx / length) * gap, + y: box.y + dy * scale + (dy / length) * gap, + }; +} + +export type EdgeLine = { x1: number; y1: number; x2: number; y2: number }; + +/** The straight line of an edge, from the edge of one box to the edge of the other, rounded to 0.1. */ +export function edgeLine(from: NodeBox, to: NodeBox): EdgeLine { + const start = boundaryPoint(from, to, 1); + const end = boundaryPoint(to, from, 3); + const round = (value: number) => Math.round(value * 10) / 10; + return { x1: round(start.x), y1: round(start.y), x2: round(end.x), y2: round(end.y) }; +} + +/** How far a label stands off its line. */ +const LABEL_OFFSET = 6; + +/** + * Where an edge's label sits: `at` along the line, stood off it along its + * normal on the upper side (right of a vertical line) or the other, anchored + * away from the line. + */ +export function edgeLabel( + line: EdgeLine, + at = 0.5, + side: LabelSide = "above", +): { x: number; y: number; anchor: "start" | "middle" | "end" } { + const dx = line.x2 - line.x1; + const dy = line.y2 - line.y1; + const length = Math.hypot(dx, dy) || 1; + const upper = dx > 0 || (dx === 0 && dy > 0) ? 1 : -1; + const flip = side === "above" ? upper : -upper; + const normal = { x: (flip * dy) / length, y: (-flip * dx) / length }; + const anchor = normal.x > 0.3 ? "start" : normal.x < -0.3 ? "end" : "middle"; + return { + x: Math.round((line.x1 + dx * at + normal.x * LABEL_OFFSET) * 10) / 10, + // A side label drops by a third of its height so its middle meets the line's. + y: Math.round((line.y1 + dy * at + normal.y * LABEL_OFFSET + (anchor === "middle" && normal.y < 0 ? 0 : 3)) * 10) / 10, + anchor, + }; +} + +/** + * How a node shows under a focus: `focus` for the focused stage, `near` for + * the stages an edge joins it to, `dim` for the rest, `rest` with no focus. + */ +export type FocusState = "rest" | "focus" | "near" | "dim"; + +function edgeTouches(edge: PipelineEdge, stage: StageId): boolean { + return edge.from === stage || edge.to === stage; +} + +export function nodeFocus(id: StageId, focused: StageId | null): FocusState { + if (focused === null) { + return "rest"; + } + if (id === focused) { + return "focus"; + } + const near = PIPELINE_EDGES.some( + (edge) => edgeTouches(edge, focused) && edgeTouches(edge, id), + ); + return near ? "near" : "dim"; +} + +export function edgeFocus(edge: PipelineEdge, focused: StageId | null): FocusState { + if (focused === null) { + return "rest"; + } + return edgeTouches(edge, focused) ? "focus" : "dim"; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/rich-text.tsx b/pocs/zeroth-playground/playground/compiler-view/rich-text.tsx new file mode 100644 index 00000000..431f09df --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/rich-text.tsx @@ -0,0 +1,24 @@ +/** `code` in backticks and **an option's label** in double stars; plain text otherwise. */ +const MARK = /(`[^`]+`|\*\*[^*]+\*\*)/u; + +/** One line of the stage texts with its marks dropped, for a screen reader. */ +export function plainText(text: string): string { + return text.replace(/`([^`]+)`/gu, "$1").replace(/\*\*([^*]+)\*\*/gu, "$1"); +} + +/** One line of the stage texts, with its code and its bold option labels set. */ +export const RichText: React.FC<{ text: string }> = ({ text }) => { + return ( + <> + {text.split(MARK).map((part, index) => { + if (part.startsWith("`") && part.endsWith("`") && part.length > 1) { + return {part.slice(1, -1)}; + } + if (part.startsWith("**") && part.endsWith("**") && part.length > 3) { + return {part.slice(2, -2)}; + } + return part; + })} + + ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-card.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-card.tsx new file mode 100644 index 00000000..33def970 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-card.tsx @@ -0,0 +1,86 @@ +import { flushSync } from "react-dom"; + +import { focusById } from "./focus-handoff"; +import { RichText } from "./rich-text"; +import { SampleView } from "./stage-card/sample-view"; +import { useStageFocus } from "./stage-focus"; + +import type { FocusHandoff } from "./focus-handoff"; +import type { StageSample } from "./stage-sample"; +import type { Stage } from "./stages"; + +type StageCardProps = { + stage: Stage; + sample: StageSample; + exampleTitle: string; + handoff: FocusHandoff; +}; + +const KIND_LABELS: Record = { + data: "Document", + step: "Step", + input: "Side input", +}; + +const OWNER_LABELS: Record = { + compiler: "compiler", + playground: "playground", +}; + +/** + * The focused stage: what it is, its call, what goes in and comes out, the + * facts worth knowing, and beside them what it made of the example. + */ +export const StageCard: React.FC = ({ stage, sample, exampleTitle, handoff }) => { + const { pinned, togglePin } = useStageFocus(); + const [from, to] = stage.kind === "step" ? ["In", "Out"] : ["From", "To"]; + return ( +
+
+
+ {pinned === null ? null : ( + + )} +

+ {KIND_LABELS[stage.kind]} · {OWNER_LABELS[stage.owner]} +

+
+

{stage.title}

+

+ +

+
{stage.call}
+
+
{from}
+
+ +
+
{to}
+
+ +
+
+
    + {stage.details.map((detail) => ( +
  • + +
  • + ))} +
+
+ +
+ ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx new file mode 100644 index 00000000..1a558bb3 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx @@ -0,0 +1,47 @@ +import type { StageSample } from "../stage-sample"; + +type SampleViewProps = { + exampleTitle: string; + sample: StageSample; +}; + +/** What the stage made of the example in the editor: headline, values, and a few quoted lines. */ +export const SampleView: React.FC = ({ exampleTitle, sample }) => { + const { excerpt } = sample; + return ( +
+

Live · {exampleTitle}

+

+

+ {sample.rows.length === 0 ? null : ( +
+ {sample.rows.map((row, index) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ )} + {excerpt === undefined ? null : ( +
+
{excerpt.source}
+
+            {excerpt.lines.map((line, index) => {
+              const previous = excerpt.lines[index - 1];
+              const skipped = previous !== undefined && line.number > previous.number + 1;
+              return (
+                
+                  {line.number}
+                  {line.text === "" ? " " : line.text}
+                
+              );
+            })}
+          
+
+ )} +
+ ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-focus.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-focus.tsx new file mode 100644 index 00000000..48ddfd94 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-focus.tsx @@ -0,0 +1,59 @@ +import { createContext, use } from "react"; + +import type { StageId } from "./stages"; + +/** + * Which stage the compiler view is on, shared by the graph, the stage card + * and the guide's stage references. The pointer sets the hovered stage and + * the keyboard the focused one, apart, so a pointer passing by does not + * clear a keyboard focus. A click or Enter pins a stage until it is clicked + * again. The card and the graph follow `focused`: the hovered stage, else + * the keyboard's, else the pinned one. + */ +export type StageFocus = { + focused: StageId | null; + pinned: StageId | null; + /** The pointer enters a stage; `null` when it leaves. The focus follows once the pointer settles. */ + hover: (stage: StageId | null) => void; + /** The keyboard focus enters a stage; `null` when it leaves. */ + focusKeyboard: (stage: StageId | null) => void; + /** Pins the stage, or unpins it when it is the pinned one. */ + togglePin: (stage: StageId) => void; +}; + +/** Outside the compiler view nothing is focused and nothing reacts. */ +const NO_FOCUS: StageFocus = { + focused: null, + pinned: null, + hover: () => {}, + focusKeyboard: () => {}, + togglePin: () => {}, +}; + +export const StageFocusContext = createContext(NO_FOCUS); + +export function useStageFocus(): StageFocus { + return use(StageFocusContext); +} + +/** The focus as a stage's target sees it, with the handlers that hover, keyboard-focus and pin that stage. */ +export function useStageTarget(stage: StageId) { + const { focused, pinned, hover, focusKeyboard, togglePin } = useStageFocus(); + return { + focused, + pinned, + togglePin, + handlers: { + onMouseEnter: () => hover(stage), + onMouseLeave: () => hover(null), + onFocus: (event: React.FocusEvent) => { + // A click focuses the target too; only a keyboard focus holds the stage. + if (event.currentTarget.matches(":focus-visible")) { + focusKeyboard(stage); + } + }, + onBlur: () => focusKeyboard(null), + onClick: () => togglePin(stage), + }, + }; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx new file mode 100644 index 00000000..7daca14a --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx @@ -0,0 +1,55 @@ +import { flushSync } from "react-dom"; + +import { focusById } from "./focus-handoff"; +import { useStageFocus } from "./stage-focus"; + +import type { FocusHandoff } from "./focus-handoff"; +import type { StageWithSample } from "./stage-sample"; + +type StageOverviewProps = { + exampleTitle: string; + /** Every stage in pipeline order, with what it made of the example. */ + samples: readonly StageWithSample[]; + handoff: FocusHandoff; +}; + +/** + * The card with no stage focused: every stage in pipeline order with what + * it made of the example, one line each. A click pins the stage and moves + * the keyboard focus to the card's "All stages", since the row goes away. + */ +export const StageOverview: React.FC = ({ exampleTitle, samples, handoff }) => { + const { togglePin } = useStageFocus(); + return ( +
+
+

Overview

+

{exampleTitle}, stage by stage

+

+ Hover a stage in the graph or in the guide to open its card here; click to pin it. +

+
+
    + {samples.map(({ stage, sample }, index) => ( +
  1. + +
  2. + ))} +
+
+ ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx new file mode 100644 index 00000000..6de5b4bd --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx @@ -0,0 +1,30 @@ +import { stageById } from "./stages"; +import { useStageTarget } from "./stage-focus"; + +import type { StageId } from "./stages"; + +type StageRefProps = { + stage: StageId; + children: React.ReactNode; +}; + +/** + * A stage named in the guide's prose: hovering or focusing it focuses the + * stage in the graph and opens its card, a click pins it there. + */ +export const StageRef: React.FC = ({ stage, children }) => { + const { focused, pinned, handlers } = useStageTarget(stage); + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts new file mode 100644 index 00000000..dd05ad18 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; + +import { compile } from "../../compiler"; +import { exampleById } from "../../examples/catalog"; +import { STAGES } from "./stages"; +import { stageSample } from "./stage-sample"; + +import type { CompilerOptions } from "../../compiler"; +import type { SampleInput } from "./stage-sample"; + +/** The example as the view samples it, with the options it opens with unless others are given. */ +function inputOf(id: string, asked?: CompilerOptions): SampleInput { + const example = exampleById(id); + if (example === undefined) { + throw new Error(`no example ${id}`); + } + const options = asked ?? example.options; + return { + exampleTitle: example.title, + irText: example.ir, + options, + compilation: compile(example.ir, { options }), + }; +} + +function inputOfText(irText: string, options: CompilerOptions = {}): SampleInput { + return { exampleTitle: "Edited", irText, options, compilation: compile(irText, { options }) }; +} + +const queue = inputOf("queue"); +const drones = inputOf("drones"); +const forkClocked = inputOf("fork-clocked"); + +describe("stageSample on a net that compiles", () => { + it("marks every stage's sample ok", () => { + // GIVEN Queue as it opens, which compiles + // THEN every stage's sample is ok + for (const stage of STAGES) { + expect(stageSample(stage.id, queue).tone, stage.id).toBe("ok"); + } + }); + + it("quotes the IR and counts what parses", () => { + // GIVEN Queue as it opens + // WHEN the IR text is sampled + const text = stageSample("ir-text", queue); + // THEN it counts the lines and quotes the first, and the parse counts the places and transitions + expect(text.headline).toBe(`${queue.irText.split("\n").length} lines of YAML.`); + expect(text.excerpt?.lines[0]).toEqual({ number: 1, text: "name: queue", lit: false }); + expect(stageSample("parse", queue).headline).toBe("Parsed: 2 places, 2 transitions."); + }); + + it("names the options that apply and the ones the panel sets", () => { + // GIVEN Queue as it opens, with the modular shape and a time step of 0.5 + // WHEN the options are sampled + const options = stageSample("options", queue); + // THEN the panel's two options are named, and Slots, which a plain net has no use for, is muted + expect(options.headline).toContain("the panel sets Shape modular, Time step 0.5"); + expect(options.rows.find((row) => row.label === "Time step")).toEqual({ label: "Time step", value: "0.5" }); + expect(options.rows.find((row) => row.label === "Slots")?.muted).toBe(true); + }); + + it("reads the graph's language, theory and modules", () => { + // GIVEN Queue as it opens, and Queue under the monolithic shape + // WHEN the graph is sampled + const monolithic = inputOf("queue", { dt: 0.5 }); + // THEN the modular graph has four modules and the monolithic one a single root module + expect(stageSample("graph", queue).headline).toBe("A linear graph in LRA: 4 modules, 6 variables."); + const graph = stageSample("graph", monolithic); + expect(graph.headline).toBe("A linear graph in LRA: 1 module, 4 variables."); + expect(graph.rows).toContainEqual({ label: "modules", value: "Queue" }); + expect(graph.rows).toContainEqual({ label: "root", value: "one module, Queue" }); + expect(stageSample("code-parser", queue).headline).toContain("No code strings"); + }); + + it("lists the files with net.py first and quotes it", () => { + // GIVEN Queue as it opens + // WHEN the files are sampled + const files = stageSample("files", queue); + // THEN net.py comes first and the excerpt quotes it from its first line + expect(files.rows[0]?.label).toBe("net.py"); + expect(files.excerpt?.source).toBe("net.py"); + expect(files.excerpt?.lines[0]?.number).toBe(1); + }); + + it("picks a nested line for the provenance and lights matching Python for the hover", () => { + // GIVEN Queue as it opens + // WHEN the provenance and the hover are sampled + const provenance = stageSample("provenance", queue); + const hover = stageSample("hover", queue); + // THEN the provenance names the innermost of the ranges at its line, and the hover lights Arrive's lines + expect(provenance.headline).toMatch(/^net\.py line \d+: \d+ ranges hold it; the innermost is line/u); + expect(provenance.rows.some((row) => row.label === "source")).toBe(true); + expect(hover.rows[0]?.value).toContain("transition Arrive"); + expect(hover.excerpt?.lines.length).toBeGreaterThan(0); + expect(hover.excerpt?.lines.every((line) => line.lit)).toBe(true); + expect(provenance.excerpt?.lines.filter((line) => line.lit)).toHaveLength(1); + }); +}); + +describe("stageSample on a refused net", () => { + it("counts the code strings the absent parser leaves refused", () => { + // GIVEN Drones as it opens, a net with five code strings + // WHEN the code parser is sampled + const parser = stageSample("code-parser", drones); + // THEN it is refused, and lists each string by surface and IR path + expect(parser.tone).toBe("refused"); + expect(parser.headline).toBe("5 code strings and no parser: 5 code-not-parsed errors."); + expect(parser.rows.map((row) => `${row.label} ${row.value}`)).toEqual([ + "dynamics dynamics.Drain.code", + "lambda transitions.Launch.rate", + "kernel transitions.Launch.kernel", + "lambda transitions.Land.guard", + "kernel transitions.Land.kernel", + ]); + }); + + it("refuses at lower with the codes and lines, and leaves the later stages idle", () => { + // GIVEN Drones as it opens + // WHEN the lowering and the diagnostics are sampled + const lower = stageSample("lower", drones); + const diagnostics = stageSample("diagnostics", drones); + // THEN the lowering is refused with lined errors, the stages after it idle, and the diagnostics list five errors + expect(lower.tone).toBe("refused"); + expect(lower.rows[0]?.value).toMatch(/^code-not-parsed: /u); + expect(lower.rows[0]?.label).toMatch(/^line \d+$/u); + for (const stage of ["graph", "emit", "files"] as const) { + expect(stageSample(stage, drones).tone, stage).toBe("idle"); + } + expect(diagnostics.headline).toBe("5 errors, 0 warnings."); + expect(diagnostics.tone).toBe("ok"); + expect(diagnostics.rows.every((row) => row.error === true)).toBe(true); + }); + + it("leaves the code parser idle when the lowering refused the net before it read the code", () => { + // GIVEN a rate that reads its tokens, under clock rates, which arm a clock with a constant + const clocked = inputOfText( + [ + "name: decay", + "kind: stochastic", + "places:", + " Atoms:", + "transitions:", + " Decay:", + " inputs:", + " Atoms:", + " rate: |", + " return input.Atoms.length;", + "", + ].join("\n"), + { rates: "clock" }, + ); + // WHEN the code parser is sampled + const parser = stageSample("code-parser", clocked); + // THEN the clocks refuse the rate before any code is read, so the parser idles + expect(clocked.compilation.errors.map((error) => error.code)).toEqual(["clocks-rate-code"]); + expect(parser.tone).toBe("idle"); + }); + + it("still traces the IR and quotes a provenance from it", () => { + // GIVEN Drones as it opens, which the lowering refuses + // THEN the trace has no Python, the provenance quotes an IR line, and the hover has nothing to light + expect(stageSample("trace", drones).rows[1]).toMatchObject({ label: "Python", muted: true }); + expect(stageSample("provenance", drones).headline).toMatch(/^IR line \d+/u); + expect(stageSample("hover", drones).headline).toContain("no Python to light"); + }); +}); + +describe("stageSample under clocks", () => { + it("reads an SPN graph with its hidden clocks", () => { + // GIVEN Fork under clocks as it opens + // WHEN the graph is sampled + const graph = stageSample("graph", forkClocked); + // THEN it is an SPN graph that hides one clock per transition, emitted with next and flow + expect(graph.headline).toBe("An SPN graph: 6 modules, 12 variables."); + expect(graph.rows).toContainEqual({ label: "hidden", value: "clk_TakeLeft, clk_TakeRight, clk_Return" }); + expect(stageSample("emit", forkClocked).rows).toContainEqual({ label: "step method", value: "next and flow" }); + }); + + it("lights each line once, written as runs, and says the excerpt is cut", () => { + // GIVEN Fork under clocks as it opens + // WHEN the hover is sampled + const hover = stageSample("hover", forkClocked); + // THEN the lit lines are listed as runs, and the excerpt shows the first 8 of them, once each + expect(hover.headline).toContain("lights 18 lines of net.py"); + expect(hover.rows).toContainEqual({ label: "net.py", value: "lines 11, 15, 19, 23–34, 73, 85, 101" }); + expect(hover.excerpt?.source).toBe("net.py · first 8 of 18 lit lines"); + expect(new Set(hover.excerpt?.lines.map((line) => line.number)).size).toBe(8); + }); + + it("greys the options clocks fix", () => { + // GIVEN Fork under clocks as it opens + // WHEN the options are sampled + const options = stageSample("options", forkClocked); + // THEN Shape is muted and reads as not used, not as its default, and Rates reads clock + expect(options.rows.find((row) => row.label === "Shape")).toEqual({ + label: "Shape", + value: "not used: Clock rates compose the modules in continuous time; there is no step.", + muted: true, + }); + expect(options.rows.find((row) => row.label === "Rates")).toEqual({ label: "Rates", value: "clock" }); + }); +}); + +describe("stageSample on a text that does not parse", () => { + it("reports a YAML error at its line and idles everything after the parse", () => { + // GIVEN a text with an unclosed bracket + const broken = inputOfText("name: x\nkind: [plain\n"); + // WHEN the parse is sampled + const parse = stageSample("parse", broken); + // THEN it is refused with a YAML error, and the stages after it idle + expect(parse.tone).toBe("refused"); + expect(parse.rows[0]?.value).toMatch(/^yaml-syntax: /u); + expect(stageSample("lower", broken).tone).toBe("idle"); + expect(stageSample("hover", broken).tone).toBe("idle"); + }); + + it("reports every schema issue", () => { + // GIVEN a document with an unknown kind and a place name that is not UpperCamelCase + const odd = inputOfText("name: x\nkind: odd\nplaces:\n a:\ntransitions: {}\n"); + // WHEN the parse is sampled + const parse = stageSample("parse", odd); + // THEN both issues are listed, each at its line + expect(parse.headline).toBe("Refused with 2 errors."); + expect(parse.rows.map((row) => row.label)).toEqual(["line 2", "line 4"]); + }); +}); diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts new file mode 100644 index 00000000..89a64ad4 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts @@ -0,0 +1,89 @@ +import { codeParserSample, irSample, irTextSample, optionsSample, parseSample } from "./stage-sample/document-samples"; +import { emitSample, filesSample, graphSample, lowerSample } from "./stage-sample/graph-samples"; +import { + diagnosticsSample, + hoverSample, + provenanceSample, + traceSample, + tracesSample, +} from "./stage-sample/trace-samples"; +import { STAGES } from "./stages"; + +import type { Compilation, CompilerOptions } from "../../compiler"; +import type { Stage, StageId } from "./stages"; + +/** + * What one stage made of the example in the editor: a headline, a few + * labelled values and, where it helps, a few lines quoted from a text. Pure: + * everything is read off the compile the app already ran. + */ + +export type SampleInput = { + exampleTitle: string; + irText: string; + /** The options the panel holds, as the compile was asked for them. */ + options: CompilerOptions; + compilation: Compilation; +}; + +/** `ok`: the stage ran. `refused`: it stopped the compile. `idle`: an earlier stage stopped it first. */ +export type SampleTone = "ok" | "refused" | "idle"; + +export type SampleRow = { + label: string; + value: string; + /** A value that does not bear on this net, such as an option that does not apply. */ + muted?: boolean; + /** An error the stage stopped on. */ + error?: boolean; +}; + +type ExcerptLine = { + /** 1-based, in the source text. */ + number: number; + text: string; + /** A line the sample points at, lit as the editors light it. */ + lit: boolean; +}; + +/** Lines quoted from one text, in order; the numbers may skip. */ +export type SampleExcerpt = { + /** The text the lines come from: `net.py`, `IR`. */ + source: string; + lines: ExcerptLine[]; +}; + +export type StageSample = { + tone: SampleTone; + headline: string; + rows: SampleRow[]; + excerpt?: SampleExcerpt; +}; + +const SAMPLES: Record StageSample> = { + "ir-text": irTextSample, + parse: parseSample, + ir: irSample, + options: optionsSample, + "code-parser": codeParserSample, + lower: lowerSample, + graph: graphSample, + emit: emitSample, + files: filesSample, + trace: traceSample, + traces: tracesSample, + provenance: provenanceSample, + hover: hoverSample, + diagnostics: diagnosticsSample, +}; + +export function stageSample(stage: StageId, input: SampleInput): StageSample { + return SAMPLES[stage](input); +} + +export type StageWithSample = { stage: Stage; sample: StageSample }; + +/** Every stage in pipeline order, with what it made of the example. */ +export function stageSamples(input: SampleInput): StageWithSample[] { + return STAGES.map((stage) => ({ stage, sample: stageSample(stage.id, input) })); +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts new file mode 100644 index 00000000..a26b639c --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts @@ -0,0 +1,128 @@ +import { codeStrings, optionStates, yamlKeys } from "../../../compiler"; +import { NOT_USED, OPTION_SECTIONS, valueLabel } from "../../options/option-labels"; +import { excerptOf } from "./excerpt"; +import { notParsed } from "./not-reached"; +import { diagnosticRow, plural } from "./rows"; + +import type { CodeString, PetriNetIr } from "../../../compiler"; +import type { SampleInput, StageSample } from "../stage-sample"; + +/** A code string's IR path: `transitions.Take.rate`. */ +function codePath({ item, field }: CodeString): string { + return `${item.kind === "dynamics" ? "dynamics" : "transitions"}.${item.name}.${field}`; +} + +function markingOf(ir: PetriNetIr): string { + const entries = Object.entries(ir.marking ?? {}).map(([place, tokens]) => + typeof tokens === "number" ? `${place} ${tokens}` : `${place} ${plural(tokens.length, "token")}`, + ); + return entries.length === 0 ? "every place empty" : entries.join(", "); +} + +export function irTextSample({ irText }: SampleInput): StageSample { + const lines = irText.split("\n"); + const sections = [ + ...new Set(yamlKeys(irText).flatMap((key) => (key.path.length === 1 ? key.path : []))), + ]; + return { + tone: "ok", + headline: `${plural(lines.length, "line")} of YAML.`, + rows: [ + { label: "sections", value: sections.join(", ") || "none" }, + { label: "characters", value: String(irText.length) }, + ], + excerpt: excerptOf("IR", irText, 1), + }; +} + +export function parseSample({ compilation }: SampleInput): StageSample { + const { ir, errors } = compilation; + if (ir === null) { + return { + tone: "refused", + headline: `Refused with ${plural(errors.length, "error")}.`, + rows: errors.map((error) => diagnosticRow(error, "error")), + }; + } + const places = Object.keys(ir.places).length; + const transitions = Object.keys(ir.transitions).length; + return { + tone: "ok", + headline: `Parsed: ${plural(places, "place")}, ${plural(transitions, "transition")}.`, + rows: [ + { label: "kind", value: ir.kind }, + { label: "colours", value: String(Object.keys(ir.colours ?? {}).length) }, + { label: "dynamics", value: String(Object.keys(ir.dynamics ?? {}).length) }, + { label: "code strings", value: String(codeStrings(ir).length) }, + ], + }; +} + +export function irSample({ compilation }: SampleInput): StageSample { + const { ir } = compilation; + if (ir === null) { + return notParsed(); + } + const colours = Object.keys(ir.colours ?? {}); + return { + tone: "ok", + headline: `The net ${ir.name}, ${ir.kind}.`, + rows: [ + { label: "places", value: Object.keys(ir.places).join(", ") }, + { label: "transitions", value: Object.keys(ir.transitions).join(", ") }, + { label: "marking", value: markingOf(ir) }, + ...(colours.length === 0 ? [] : [{ label: "colours", value: colours.join(", ") }]), + ], + }; +} + +export function optionsSample({ compilation, options }: SampleInput): StageSample { + const { ir } = compilation; + if (ir === null) { + return notParsed(); + } + const states = optionStates(ir, options); + const applying = states.filter((state) => state.notApplicable === undefined); + const labelled = OPTION_SECTIONS.flatMap((section) => section.options); + const set = labelled.flatMap((labels) => { + const value = options[labels.name]; + return value === undefined ? [] : [`${labels.label} ${valueLabel(labels, String(value))}`]; + }); + const rows = labelled.flatMap((labels) => { + const state = states.find((candidate) => candidate.name === labels.name); + if (state === undefined) { + return []; + } + return state.notApplicable === undefined + ? [{ label: labels.label, value: valueLabel(labels, state.value) }] + : [{ label: labels.label, value: `${NOT_USED}: ${state.notApplicable}`, muted: true }]; + }); + return { + tone: "ok", + headline: `${applying.length} of ${states.length} options apply; ${ + set.length === 0 ? "each keeps the compiler's default" : `the panel sets ${set.join(", ")}` + }.`, + rows, + }; +} + +export function codeParserSample({ compilation }: SampleInput): StageSample { + const { ir, errors } = compilation; + if (ir === null) { + return notParsed(); + } + const codes = codeStrings(ir); + if (codes.length === 0) { + return { tone: "ok", headline: "No code strings: the lowering never asks for a parser.", rows: [] }; + } + const rows = codes.map((code) => ({ label: code.surface, value: codePath(code) })); + const refused = errors.filter((error) => error.code === "code-not-parsed").length; + if (compilation.graph === null && refused === 0) { + return { tone: "idle", headline: "Not reached: the lowering refused the net before it read the code.", rows }; + } + return { + tone: refused > 0 ? "refused" : "ok", + headline: `${plural(codes.length, "code string")} and no parser: ${plural(refused, "code-not-parsed error")}.`, + rows, + }; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts new file mode 100644 index 00000000..4e482c8f --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts @@ -0,0 +1,57 @@ +import type { SampleExcerpt } from "../stage-sample"; + +type LineRange = { startLine: number; endLine: number }; + +/** The most lines a sample quotes. */ +export const EXCERPT_LINES = 8; + +/** `count` lines of the text from `firstLine`, at most `EXCERPT_LINES`, lighting the ones listed. */ +export function excerptOf( + source: string, + text: string, + firstLine: number, + count = EXCERPT_LINES, + lit: readonly number[] = [], +): SampleExcerpt { + const lines = text + .split("\n") + .slice(firstLine - 1, firstLine - 1 + Math.min(count, EXCERPT_LINES)) + .map((line, index) => ({ number: firstLine + index, text: line, lit: lit.includes(firstLine + index) })); + return { source, lines }; +} + +/** The listed lines of the text alone, each lit, up to `EXCERPT_LINES`. */ +export function excerptOfLines(source: string, text: string, numbers: readonly number[]): SampleExcerpt { + const lines = text.split("\n"); + return { + source, + lines: numbers.slice(0, EXCERPT_LINES).map((number) => ({ number, text: lines[number - 1] ?? "", lit: true })), + }; +} + +export function lineSpan({ startLine, endLine }: LineRange): string { + return startLine === endLine ? `line ${startLine}` : `lines ${startLine}–${endLine}`; +} + +/** The lines the ranges cover, each once, in order. */ +export function coveredLines(ranges: readonly LineRange[]): number[] { + const lines = ranges.flatMap(({ startLine, endLine }) => + Array.from({ length: endLine - startLine + 1 }, (_, index) => startLine + index), + ); + return [...new Set(lines)].toSorted((a, b) => a - b); +} + +/** Line numbers in order, written as runs: `line 18`, `lines 11, 15, 23–34`. */ +export function linesLabel(numbers: readonly number[]): string { + const runs: { start: number; end: number }[] = []; + for (const number of numbers) { + const last = runs.at(-1); + if (last !== undefined && number === last.end + 1) { + last.end = number; + } else { + runs.push({ start: number, end: number }); + } + } + const written = runs.map(({ start, end }) => (start === end ? `${start}` : `${start}–${end}`)).join(", "); + return `${numbers.length === 1 ? "line" : "lines"} ${written}`; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/graph-samples.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/graph-samples.ts new file mode 100644 index 00000000..948f147e --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/graph-samples.ts @@ -0,0 +1,106 @@ +import { excerptOf } from "./excerpt"; +import { notLowered, notParsed } from "./not-reached"; +import { countsBy, diagnosticRows, listed, plural } from "./rows"; + +import type { LinearGraph, ModuleGraph } from "../../../compiler"; +import type { SampleInput, StageSample } from "../stage-sample"; + +function theoriesOf(graph: ModuleGraph): string { + if (graph.language === "spn") { + return "SPN"; + } + return [...new Set(graph.modules.map((module) => module.theory))].join(", "); +} + +/** How a linear graph's system is made up, naming its module by the class the Python uses. */ +function rootOf(graph: LinearGraph): string { + const { root } = graph; + if (root.kind !== "single") { + return `compose of ${root.modules.length}`; + } + const module = graph.modules.find((candidate) => candidate.instance === root.module); + return `one module, ${module?.className ?? root.module}`; +} + +export function lowerSample({ compilation }: SampleInput): StageSample { + const { ir, graph, errors } = compilation; + if (ir === null) { + return notParsed(); + } + if (graph === null) { + return { + tone: "refused", + headline: `Refused with ${plural(errors.length, "error")}; the IR stands alone.`, + rows: diagnosticRows(errors, []), + }; + } + const { options } = compilation; + return { + tone: "ok", + headline: `Lowered to ${graph.language === "spn" ? "an SPN graph" : "a linear graph"} in ${theoriesOf(graph)}.`, + rows: [ + { label: "rates", value: options.rates }, + { label: "shape", value: graph.language === "spn" ? "composed per transition and place" : options.shape }, + ], + }; +} + +export function graphSample(input: SampleInput): StageSample { + const { graph } = input.compilation; + if (graph === null) { + return notLowered(input); + } + const modules = graph.modules.map((module) => module.className); + if (graph.language === "spn") { + return { + tone: "ok", + headline: `An SPN graph: ${plural(modules.length, "module")}, ${plural(graph.variables.length, "variable")}.`, + rows: [ + { label: "language", value: "spn" }, + { label: "modules", value: listed(modules) }, + { label: "variables", value: countsBy(graph.variables, (variable) => variable.role) }, + { label: "hidden", value: graph.hidden.join(", ") || "none" }, + ], + }; + } + return { + tone: "ok", + headline: `A linear graph in ${theoriesOf(graph)}: ${plural(modules.length, "module")}, ${plural(graph.variables.length, "variable")}.`, + rows: [ + { label: "language", value: "linear" }, + { label: "modules", value: listed(modules) }, + { label: "variables", value: countsBy(graph.variables, (variable) => variable.role) }, + { label: "root", value: rootOf(graph) }, + ], + }; +} + +export function emitSample(input: SampleInput): StageSample { + const { ir, graph, files } = input.compilation; + if (ir === null || graph === null) { + return notLowered(input); + } + const { layout } = input.compilation.options; + return { + tone: "ok", + headline: `${plural(files.length, "file")} from ${graph.language === "spn" ? "the SPN emitter" : "the linear emitter"}.`, + rows: [ + { label: "files", value: layout === "per-module" ? "one per module" : "one file" }, + { label: "step method", value: graph.language === "spn" ? "next and flow" : "next" }, + ], + }; +} + +export function filesSample(input: SampleInput): StageSample { + const { files } = input.compilation; + const main = files[0]; + if (main === undefined) { + return notLowered(input); + } + return { + tone: "ok", + headline: `${plural(files.length, "file")}, ${main.path} first.`, + rows: files.map((file) => ({ label: file.path, value: plural(file.text.split("\n").length, "line") })), + excerpt: excerptOf(main.path, main.text, 1), + }; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/not-reached.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/not-reached.ts new file mode 100644 index 00000000..3dc81e59 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/not-reached.ts @@ -0,0 +1,19 @@ +import { plural } from "./rows"; + +import type { SampleInput, StageSample } from "../stage-sample"; + +/** Why a stage after the parse has nothing to show. */ +export function notParsed(): StageSample { + return { tone: "idle", headline: "Not reached: the text does not parse as an IR.", rows: [] }; +} + +/** Why a stage after the lowering has nothing to show. */ +export function notLowered(input: SampleInput): StageSample { + return input.compilation.ir === null + ? notParsed() + : { + tone: "idle", + headline: `Not reached: the lowering refused the net with ${plural(input.compilation.errors.length, "error")}.`, + rows: [], + }; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/rows.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/rows.ts new file mode 100644 index 00000000..fe5c2b1c --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/rows.ts @@ -0,0 +1,54 @@ +import { itemLabel } from "../../../compiler"; + +import type { Diagnostic, Provenance } from "../../../compiler"; +import type { SampleRow } from "../stage-sample"; + +export function plural(count: number, one: string, many = `${one}s`): string { + return `${count} ${count === 1 ? one : many}`; +} + +/** The names joined, the ones past `limit` counted. */ +export function listed(names: readonly string[], limit = 6): string { + return names.length <= limit + ? names.join(", ") + : `${names.slice(0, limit).join(", ")} and ${names.length - limit} more`; +} + +/** How many values share each key, in first-seen order: `2 input, 1 state`. */ +export function countsBy(values: readonly T[], key: (value: T) => string): string { + const counts = new Map(); + for (const value of values) { + counts.set(key(value), (counts.get(key(value)) ?? 0) + 1); + } + return [...counts].map(([name, count]) => `${count} ${name}`).join(", "); +} + +export function sourceName(provenance: Provenance): string | undefined { + return provenance.source === undefined ? undefined : itemLabel(provenance.source); +} + +export function provenanceRows(provenance: Provenance): SampleRow[] { + const source = sourceName(provenance); + return [ + { label: "what", value: provenance.what }, + ...(provenance.why === undefined || provenance.why === "" ? [] : [{ label: "why", value: provenance.why }]), + ...(provenance.ir === undefined ? [] : [{ label: "ir", value: provenance.ir }]), + ...(source === undefined ? [] : [{ label: "source", value: source }]), + ]; +} + +export function diagnosticRow(diagnostic: Diagnostic, severity: "error" | "warning"): SampleRow { + const item = diagnostic.item.name === "" ? "" : ` (${itemLabel(diagnostic.item)})`; + return { + label: diagnostic.line === undefined ? "no line" : `line ${diagnostic.line}`, + value: `${diagnostic.code}: ${diagnostic.message}${item}`, + ...(severity === "error" ? { error: true } : {}), + }; +} + +export function diagnosticRows(errors: readonly Diagnostic[], warnings: readonly Diagnostic[]): SampleRow[] { + return [ + ...errors.map((error) => diagnosticRow(error, "error")), + ...warnings.map((warning) => diagnosticRow(warning, "warning")), + ]; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/trace-samples.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/trace-samples.ts new file mode 100644 index 00000000..70180816 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/trace-samples.ts @@ -0,0 +1,136 @@ +import { matchingLines } from "../../../compiler"; +import { coveredLines, EXCERPT_LINES, excerptOf, excerptOfLines, lineSpan, linesLabel } from "./excerpt"; +import { notParsed } from "./not-reached"; +import { diagnosticRows, plural, provenanceRows, sourceName } from "./rows"; + +import type { CompiledFile, Trace, TraceRange } from "../../../compiler"; +import type { SampleInput, StageSample } from "../stage-sample"; + +/** The text a sample quotes its traces from: the first file, else the IR. */ +function tracedText({ irText, compilation }: SampleInput): { source: string; text: string; trace: Trace } { + const main: CompiledFile | undefined = compilation.files[0]; + return main === undefined + ? { source: "IR", text: irText, trace: compilation.irTrace } + : { source: main.path, text: main.text, trace: main.trace }; +} + +/** The ranges holding a line, innermost first, as `provenanceAt` ranks them. */ +function holders(trace: Trace, line: number): TraceRange[] { + return trace + .filter((range) => range.startLine <= line && line <= range.endLine) + .toSorted((a, b) => a.endLine - a.startLine - (b.endLine - b.startLine)); +} + +export function traceSample({ irText, compilation }: SampleInput): StageSample { + const { ir, irTrace, files } = compilation; + if (ir === null) { + return notParsed(); + } + return { + tone: "ok", + headline: `${plural(irTrace.length, "range")} over the IR text.`, + rows: [ + { label: "IR", value: `${plural(irTrace.length, "range")} over ${plural(irText.split("\n").length, "line")}` }, + ...(files.length === 0 + ? [{ label: "Python", value: "none: the lowering refused", muted: true }] + : files.map((file) => ({ label: file.path, value: `${plural(file.trace.length, "range")}, written by emit` }))), + ], + }; +} + +export function tracesSample(input: SampleInput): StageSample { + if (input.compilation.ir === null) { + return notParsed(); + } + const { source, text, trace } = tracedText(input); + const example = + trace.find((range) => range.provenance.source !== undefined && range.provenance.why !== undefined) ?? trace[0]; + if (example === undefined) { + return { tone: "ok", headline: `No ranges in ${source}.`, rows: [] }; + } + return { + tone: "ok", + headline: `One range of ${source}, ${lineSpan(example)}.`, + rows: provenanceRows(example.provenance), + excerpt: excerptOf(source, text, example.startLine, example.endLine - example.startLine + 1), + }; +} + +export function provenanceSample(input: SampleInput): StageSample { + if (input.compilation.ir === null) { + return notParsed(); + } + const { source, text, trace } = tracedText(input); + const lineCount = text.split("\n").length; + const lines = Array.from({ length: lineCount }, (_, index) => index + 1); + const line = + lines.find((candidate) => { + const held = holders(trace, candidate); + return held.length >= 2 && held[0]?.provenance.source !== undefined; + }) ?? lines.find((candidate) => holders(trace, candidate).length > 0); + const held = line === undefined ? [] : holders(trace, line); + const innermost = held[0]; + if (line === undefined || innermost === undefined) { + return { tone: "ok", headline: `No line of ${source} is traced.`, rows: [] }; + } + return { + tone: "ok", + headline: `${source} line ${line}: ${plural(held.length, "range holds", "ranges hold")} it; the innermost is ${lineSpan(innermost)}.`, + rows: provenanceRows(innermost.provenance), + excerpt: excerptOf(source, text, Math.max(1, line - 2), 5, [line]), + }; +} + +export function hoverSample({ irText, compilation }: SampleInput): StageSample { + const { ir, irTrace, files } = compilation; + if (ir === null) { + return notParsed(); + } + const range = + irTrace.find((candidate) => candidate.provenance.source?.kind === "transition") ?? + irTrace.find((candidate) => candidate.provenance.source?.kind === "place"); + if (range === undefined) { + return { tone: "ok", headline: "No IR range names a place or transition.", rows: [] }; + } + const source = sourceName(range.provenance) ?? ""; + const irRow = { label: "IR", value: `${lineSpan(range)}, ${source}` }; + const matched = files.map((file) => ({ file, lines: matchingLines(file.trace, range.provenance) })); + const main = matched[0]; + if (main === undefined) { + return { + tone: "ok", + headline: `A hover on ${source} lights its node in the preview; there is no Python to light.`, + rows: [irRow], + excerpt: excerptOf("IR", irText, range.startLine, range.endLine - range.startLine + 1), + }; + } + const lit = coveredLines(main.lines); + const excerptSource = + lit.length > EXCERPT_LINES ? `${main.file.path} · first ${EXCERPT_LINES} of ${lit.length} lit lines` : main.file.path; + return { + tone: "ok", + headline: `A hover on ${source} in the IR lights ${plural(lit.length, "line")} of ${main.file.path}.`, + rows: [ + irRow, + ...matched.map(({ file, lines }) => ({ + label: file.path, + value: lines.length === 0 ? "nothing" : linesLabel(coveredLines(lines)), + ...(lines.length === 0 ? { muted: true } : {}), + })), + ], + ...(lit.length === 0 ? {} : { excerpt: excerptOfLines(excerptSource, main.file.text, lit) }), + }; +} + +export function diagnosticsSample({ compilation }: SampleInput): StageSample { + const { errors, warnings } = compilation; + if (errors.length === 0 && warnings.length === 0) { + return { tone: "ok", headline: "No diagnostics: the net compiles cleanly.", rows: [] }; + } + // The list of errors stops nothing itself: its rows are red, the box is not. + return { + tone: "ok", + headline: `${plural(errors.length, "error")}, ${plural(warnings.length, "warning")}.`, + rows: diagnosticRows(errors, warnings), + }; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stages.ts b/pocs/zeroth-playground/playground/compiler-view/stages.ts new file mode 100644 index 00000000..85c9ba0e --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stages.ts @@ -0,0 +1,318 @@ +import { DWELL_MS } from "../pointer/pointer-motion"; + +/** + * The parts of the compilation, as the compiler view documents them: the + * documents that pass between steps, the steps that turn one into the next, + * and the two side inputs of the lowering. Each entry is the text of its + * card; the graph places them in `pipeline-layout.ts`, and `stage-sample.ts` + * reads each one off the example in the editor. + */ + +export type StageId = + | "ir-text" + | "parse" + | "ir" + | "options" + | "code-parser" + | "lower" + | "graph" + | "emit" + | "files" + | "trace" + | "traces" + | "provenance" + | "hover" + | "diagnostics"; + +/** A document passed between steps, a function, or an input beside the main line. */ +export type StageKind = "data" | "step" | "input"; + +export type Stage = { + id: StageId; + kind: StageKind; + /** Who provides it: the compiler, or the playground around it. */ + owner: "compiler" | "playground"; + /** The name on the node. */ + label: string; + /** The line under the name on the node: its type, or what it uses. */ + note: string; + title: string; + /** One line: what it is or does. */ + summary: string; + /** The call, or the type of the document. Backticks mark code in the strings below. */ + call: string; + input: string; + output: string; + details: readonly string[]; +}; + +/** Pipeline order: the order the overview lists them in and the guide numbers them. */ +export const STAGES: readonly Stage[] = [ + { + id: "ir-text", + kind: "data", + owner: "playground", + label: "IR text", + note: "YAML", + title: "IR text", + summary: "The Petri net IR as block-style YAML: the text the IR editor of the Examples view holds.", + call: "irText: string", + input: "An example, then the editor", + output: "`parse`", + details: [ + "Sections: `name`, `kind`, `description` (optional prose), `colours`, `dynamics`, `places`, `marking`, `transitions`.", + "Places, transitions and arcs are records keyed by UpperCamelCase names. An entry with every field at its default is `null`, written as a bare key.", + "The record order of `transitions` is the order a step sweeps them in.", + "Code (`guard`, `rate`, `kernel`, `dynamics.code`) is bare-body TypeScript in a literal block.", + "The text carries no options: they come from the panel.", + ], + }, + { + id: "parse", + kind: "step", + owner: "compiler", + label: "parse", + note: "js-yaml · zod", + title: "Parse", + summary: "Reads the text as YAML with js-yaml, then checks it against the IR's strict zod schema, which defines the PetriNetIr type.", + call: "parsePetriNetIr(text)", + input: "IR text", + output: "`{ ok: true, ir }` or `{ ok: false, errors }`", + details: [ + "A YAML error stops the read: one `yaml-syntax` diagnostic at the line js-yaml reports. An empty text is one too, with no line.", + "A schema failure reports every issue at once, each a `schema` diagnostic at the line of the key it concerns.", + "Once the shape is right, the references across sections are checked: every place, colour and dynamics named is declared (`unknown-place`, `unknown-colour`, `unknown-dynamics`), no name is one the Python uses (`reserved-name`), each marking fits its place (`marking-invalid`, `marking-over-capacity`), and `kind` agrees with the rates (`kind-mismatch`).", + "Objects are strict, so a misspelt key is reported where it stands instead of being read as a default.", + "Place and transition names must be UpperCamelCase; the net's name must be an identifier.", + "The schema is the IR's one definition: `PetriNetIr` is inferred from it.", + ], + }, + { + id: "ir", + kind: "data", + owner: "compiler", + label: "PetriNetIr", + note: "the document", + title: "PetriNetIr", + summary: "The parsed document as plain data: places, arcs, the marking, rates or guards, colours and dynamics.", + call: "PetriNetIr", + input: "`parse`", + output: "`lower` and `trace`", + details: [ + "`kind` is `plain`, `stochastic` or `mixed`. A transition with a `rate` is stochastic.", + "An arc keyed by its place: `null` is a standard arc of weight one. A `read` arc needs the weight and leaves it; an `inhibitor` arc needs fewer.", + "A plain place starts with a count, a coloured place with one record per token.", + "It holds no compiler options: `lower` and `trace` take them beside it.", + ], + }, + { + id: "options", + kind: "input", + owner: "playground", + label: "Options", + note: "CompilerOptions", + title: "Compiler options", + summary: "The compiler's strategies, one per option. The Compiler options panel under the IR editor holds them, and the compile passes them beside the parsed document.", + call: "CompilerOptions", + input: "The Compiler options panel", + output: "`lower` (**Shape**, **Rates**, **Time step**, **Marking**, **Conflicts**, **Control**, **Slots**) and `emit` (**Files**)", + details: [ + "The panel groups them by what they decide: Structure, Rates, Choices, Coloured tokens. **Files** sits under **Shape**; **Time step** and **Marking** sit under **Rates**.", + "One table, `OPTIONS`, gives each option its values, its default, the nets it applies to and why it does not apply to a net. `resolveOptions` fills every option left out with its default, **Shape** on monolithic among them. Each example opens with its own options: the modular shape wherever it compiles the net, clocks for Fork under clocks, monolithic for the coloured nets.", + "`optionStates` greys an option that has no effect on the net. Its control reads \"not used\", and the reason takes the place of its hint: no conflict, no coloured place, clock rates.", + "A change keeps only the options that apply and differ from their default (`optionsForNet`). `compile` keeps the same ones, drops any other with an `option-not-applicable` warning, so in the playground none appears, and reports the options in force as `compilation.options`.", + "**Rates** on clock fixes the composition: **Shape**, **Time step**, **Marking** and **Control** no longer apply.", + ], + }, + { + id: "code-parser", + kind: "input", + owner: "compiler", + label: "parseCode", + note: "not passed", + title: "The code parser, absent", + summary: "The setting of `compile` that reads the document's code strings into code trees. The playground does not pass one.", + call: "parseCode?: (code, surface) => CodeFunction | undefined", + input: "Nothing", + output: "`lower`", + details: [ + "A parser that would read the strings is built on the TypeScript compiler, too large for one HTML file.", + "Without it, each code string the lowering reads is refused with `code-not-parsed`, naming its surface: `lambda`, `kernel` or `dynamics`.", + "A net without code strings never calls it.", + "Boiler, Drones and Bucket carry code and are refused. The refusal is part of what the playground shows.", + ], + }, + { + id: "lower", + kind: "step", + owner: "compiler", + label: "lower", + note: "lowerPetriNetIr", + title: "Lower", + summary: "Turns the IR into a module graph in the shape the options ask for. The graph has no target syntax yet.", + call: "lowerPetriNetIr(ir, options, parseCode)", + input: "PetriNetIr, the options in force and the code parser", + output: "`{ ok: true, graph }` or `{ ok: false, errors }`", + details: [ + "**Rates** on coin gives a linear graph (`language: \"linear\"`); on clock, an SPN graph (`language: \"spn\"`).", + "A construct it cannot express is refused per item, and the IR stays as it is. The lowering the options pick refuses by name first, before the code is read: **Shape** on modular refuses a coloured net with `modular-coloured-not-lowered` alone, so the coloured examples open on monolithic.", + "It takes the options in force: every option that does not apply to the net is already at its default, so the lowering has nothing to warn about.", + ], + }, + { + id: "graph", + kind: "data", + owner: "compiler", + label: "Module graph", + note: "linear · spn", + title: "Module graph", + summary: "Typed variables, the modules that drive them, and how the modules make up the system.", + call: "LinearGraph | SpnGraph", + input: "`lower`", + output: "`emit` and `trace`", + details: [ + "A linear module is typed in LIA or LRA: LIA for Int places, LRA for Real places and draws.", + "**Shape** on modular, what the examples open with: one module per transition and per place, composed. On monolithic, the compiler's default: one module holds the whole step.", + "Under **Marking** on Int, one LRA `Draw_` module per rated transition turns its draw into a flag. The LIA module that fires the transition reads it: its transition module, or the one module under monolithic.", + "`X(name)` reads a variable's value in this round: an input the harness writes, or a variable another module drives. A variable has one driver, and the awaits form a DAG.", + "SPN: a place is a Nat counter, a transition owns a Clock armed with `exp(rate)` and an Event it toggles; the clocks are hidden in the system.", + ], + }, + { + id: "emit", + kind: "step", + owner: "compiler", + label: "emit", + note: "zrth.sugar", + title: "Emit", + summary: "Prints the graph as Python over the zrth.sugar DSL, with the emitter of the graph's language.", + call: "emitPython(graph, layout, ir)", + input: "Module graph, the IR and **Files**", + output: "`CompiledFile[]`, `net.py` first, each with its trace", + details: [ + "Linear graphs go to the linear emitter: the step method is `next`, the spelling of zrth's spn branch.", + "SPN graphs go to the SPN emitter, with `next` and `flow` methods.", + "Each line is written with its provenance, and the lines of a class, a method or the imports are grouped under one more. The file's trace is read off what was written, so it cannot disagree with the text.", + "**Files** on one per module applies under **Shape** on modular or **Rates** on clock (`OPTIONS.layout`): `net.py` plus one file per module class.", + "On one file, and always under **Shape** on monolithic, the whole program is one file, `net.py`.", + ], + }, + { + id: "files", + kind: "data", + owner: "compiler", + label: "Python files", + note: "net.py first", + title: "Python files", + summary: "The emitted Python, `net.py` first. The Python editor of the Examples view shows one at a time, picked from the file list.", + call: "CompiledFile[] // { path, text, trace }", + input: "`emit`", + output: "The Python editor, and each file's trace to `traces`", + details: [ + "`net.py` declares the variables and binds `net` to the system: `compose(...)` of the transition and place modules, or the one module under **Shape** on monolithic. Under **Marking** on Int, the `Draw_` modules join the compose, so on monolithic `net` is `compose(draw_…, marking)`.", + "Under **Files** on one per module, `net.py` imports each class from its own file: `transition_birth.py` for `Transition_Birth`.", + "None when the lowering refuses: the IR stands alone.", + ], + }, + { + id: "trace", + kind: "step", + owner: "compiler", + label: "trace", + note: "line ranges", + title: "Trace", + summary: "Records what every line of the IR is, why it is there, and the net item it belongs to.", + call: "traceIr(ir, irText, options)", + input: "PetriNetIr, with the IR text it came from, and the options", + output: "`Trace`: `{ startLine, endLine, provenance }[]`", + details: [ + "One range per section, entry, field and arc of the YAML.", + "The Python needs no step of its own: `emit` writes each file's trace with its text, over variables, modules, methods, statements and the system.", + "Ranges nest, section over entry over field.", + "The IR trace exists as soon as the text parses, whether the lowering refuses or not.", + ], + }, + { + id: "traces", + kind: "data", + owner: "compiler", + label: "Traces", + note: "Trace per text", + title: "Traces", + summary: "The line ranges of the IR and of each shown file, each with its provenance.", + call: "Trace // TraceRange[]", + input: "`trace` for the IR, `files` for the Python", + output: "`provenanceAt`, cross-highlighting, `firstLineOf`", + details: [ + "`what`: what the lines are, as a short noun phrase.", + "`why`: why they are there, when the lines do not say it.", + "`ir`: the IR path they render or compile from, such as `transitions.Serve.rate`.", + "`source`: the place, transition, colour, dynamics or net they come from.", + ], + }, + { + id: "provenance", + kind: "step", + owner: "compiler", + label: "provenanceAt", + note: "innermost range", + title: "Provenance at a line", + summary: "Answers for one line: the provenance of the innermost range that holds it.", + call: "provenanceAt(trace, line)", + input: "A trace and a 1-based line", + output: "`Provenance`, or `null` off every range", + details: [ + "Innermost is the shortest range holding the line; of two as short, the first.", + "A line off every range shows no card and lights nothing.", + "The hover card shows `what` in bold, then `why`, then the IR path and the source.", + "Both editors share one hover provider. Each model is bound to the trace of its text, and a model showing other text gets no card.", + ], + }, + { + id: "hover", + kind: "data", + owner: "playground", + label: "Hover", + note: "lit lines · card", + title: "Hover and cross-highlighting", + summary: "A hover in one view lights the lines and nodes that match it in the others: the IR, the Python, the net preview.", + call: "matchingLines(trace, provenance)", + input: "The provenance under the pointer", + output: "Lit lines in each other editor, a lit node in the preview", + details: [ + "Two ranges match when their sources name the same item, or, when neither has a source, when one IR path equals the other or contains it: `places.Waiting` matches `places.Waiting.capacity`.", + "A range with a source never matches one without.", + `The hover takes effect once the pointer has rested ${DWELL_MS} ms, so a sweep across lines lights nothing.`, + "The editor a hover starts in lights none of its own lines; its card shows the provenance there.", + "Under **Shape** on modular, a transition in the IR lights its variables, its module class and the line that makes its instance. Under **Shape** on monolithic, it lights its draw variable, if it has a rate, and the line of the step that sets its `fire_` flag. Under **Marking** on Int, it also lights its `Draw_` module: the class, its `hit_` variable and its instance.", + "A node in the preview stands for its source: hovering place `Pool` lights its lines on both sides.", + ], + }, + { + id: "diagnostics", + kind: "data", + owner: "playground", + label: "Diagnostics", + note: "code · message · item", + title: "Diagnostics", + summary: "Why the compiler refused, or what it warns about, with the IR line when one can be found.", + call: "Diagnostic[] // { code, message, item, line? }", + input: "`parse` errors; `compile` warnings; `lower` errors", + output: "The list under the Python, and marks in the IR editor", + details: [ + "From parse: `yaml-syntax`, `schema` and the reference codes such as `unknown-place`, each at the line js-yaml or the key scan gives.", + "From compile: `option-not-applicable`, one warning per option asked for that does not apply to the net, with the reason the panel shows.", + "From lower: refusals by name, such as every `clocks-*` code, and refusals of code, here `code-not-parsed`.", + "`compile` gives each diagnostic of the options and the lowering its line with `firstLineOf`: the start of the first IR trace range whose source is the diagnostic's item.", + "A line number in the list reveals the line in the IR editor.", + ], + }, +]; + +export function stageById(id: StageId): Stage { + const stage = STAGES.find((candidate) => candidate.id === id); + if (stage === undefined) { + throw new Error(`no stage ${id}`); + } + return stage; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx b/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx new file mode 100644 index 00000000..11186d72 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx @@ -0,0 +1,99 @@ +import { OPTIONS, optionStates, parsePetriNetIr } from "../../compiler"; +import { EXAMPLES } from "../../examples/catalog"; +import { OPTION_SECTIONS, valueLabel } from "../options/option-labels"; + +import type { OptionName } from "../../compiler"; +import type { Example } from "../../examples/catalog"; +import type { OptionLabels } from "../options/option-labels"; + +/** By option, then by value, the titles of the examples that open with it. */ +type Opened = Map>; + +/** The value each example opens with, by option, counting only the examples the option applies to. */ +function openedValues(examples: readonly Example[]): Opened { + const opened: Opened = new Map(); + for (const example of examples) { + const parsed = parsePetriNetIr(example.ir); + if (!parsed.ok) { + continue; + } + for (const state of optionStates(parsed.ir, example.options)) { + if (state.notApplicable !== undefined) { + continue; + } + const byValue = opened.get(state.name) ?? new Map(); + byValue.set(state.value, [...(byValue.get(state.value) ?? []), example.title]); + opened.set(state.name, byValue); + } + } + return opened; +} + +/** What the examples open with: the most common value, then the others with the examples that take them. */ +function openedWith(labels: OptionLabels, opened: Opened): string { + const groups = [...(opened.get(labels.name) ?? new Map())].toSorted( + ([, a], [, b]) => b.length - a.length, + ); + const [first, ...rest] = groups; + if (first === undefined) { + return "no example"; + } + return [ + valueLabel(labels, first[0]), + ...rest.map(([value, titles]) => `${valueLabel(labels, value)} for ${titles.join(", ")}`), + ].join("; "); +} + +/** The values in the panel's order; a number shows the compiler's default alone. */ +function valuesOf(labels: OptionLabels): { value: string; label: string; isDefault: boolean }[] { + const fallback = String(OPTIONS[labels.name].default); + const choices = labels.choices ?? [{ value: fallback, label: fallback }]; + return choices.map(({ value, label }) => ({ value, label, isDefault: value === fallback })); +} + +/** + * Every option of the Compiler options panel, grouped as the panel groups + * them: its values with the compiler's default marked, what it applies to, + * and what the examples open with. + */ +export const StrategyTable: React.FC = () => { + const opened = openedValues(EXAMPLES); + return ( + + + + + + + + + + {OPTION_SECTIONS.map((section) => ( + + + + + {section.options.map((labels) => ( + + + + + + + ))} + + ))} +
OptionValuesApplies toExamples open with
+ {section.title} +
+ {labels.label} + {labels.label.toLowerCase() === labels.name ? null : <> ({labels.name})} + + {valuesOf(labels).map(({ value, label, isDefault }) => ( + + {label} + + ))} + {OPTIONS[labels.name].appliesTo}{openedWith(labels, opened)}
+ ); +}; diff --git a/pocs/zeroth-playground/playground/docs/code-block.css b/pocs/zeroth-playground/playground/docs/code-block.css new file mode 100644 index 00000000..de45fd5d --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/code-block.css @@ -0,0 +1,31 @@ +/* -- Code a page quotes: one row per line, a long line wrapping under a hanging indent -- */ + +.docs .code-block { + margin: 0.2rem 0 1rem; + padding: 0.55rem 0.7rem; + border-left: 2px solid var(--sw-hair); + border-radius: 0 2px 2px 0; + background: var(--sw-paper); + line-height: 1.45; +} + +.docs .code-block code { + display: block; + padding: 0; + background: none; + font-size: 0.74rem; + white-space: normal; +} + +/* The continuation of a wrapped line starts 4ch in, so it never reads as a line of its own. */ +.code-block__line { + display: block; + padding-left: 4ch; + text-indent: -4ch; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.code-block__line + .code-block__line { + margin-top: 0.15rem; +} diff --git a/pocs/zeroth-playground/playground/docs/code-block.test.tsx b/pocs/zeroth-playground/playground/docs/code-block.test.tsx new file mode 100644 index 00000000..a79b716a --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/code-block.test.tsx @@ -0,0 +1,22 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { CodeBlock } from "./code-block"; + +describe("CodeBlock", () => { + it("renders each line of a fenced block as a row of its own", () => { + // GIVEN what MDX hands `pre` for a fenced block: a `code` element whose text ends in a newline + const block = ( + + {"fire_Go = A >= 1\nA = ite(fire_Go, A - 1, A)\n"} + + ); + // WHEN it renders + const html = renderToStaticMarkup(block); + // THEN each line is one row, and the closing newline adds none + expect(Array.from(html.matchAll(/([^<]*)<\/span>/gu), (match) => match[1])).toEqual([ + "fire_Go = A >= 1", + "A = ite(fire_Go, A - 1, A)", + ]); + }); +}); diff --git a/pocs/zeroth-playground/playground/docs/code-block.tsx b/pocs/zeroth-playground/playground/docs/code-block.tsx new file mode 100644 index 00000000..96727239 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/code-block.tsx @@ -0,0 +1,34 @@ +import { Children, isValidElement } from "react"; + +import "./code-block.css"; + +/** The text of a code block's children: MDX hands `pre` one `code` element holding the text. */ +function textOf(node: React.ReactNode): string { + if (typeof node === "string") { + return node; + } + if (isValidElement<{ children?: React.ReactNode }>(node)) { + return textOf(node.props.children); + } + return Children.toArray(node).map(textOf).join(""); +} + +/** + * A fenced code block on a page, one row per line. A long line wraps under + * its own indent, so it stays readable in a docs panel 200 px wide instead + * of scrolling out of sight. + */ +export const CodeBlock: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const lines = textOf(children).replace(/\n$/u, "").split("\n"); + return ( +
+      
+        {lines.map((line, index) => (
+          
+            {line}
+          
+        ))}
+      
+    
+ ); +}; diff --git a/pocs/zeroth-playground/playground/docs/documentation.tsx b/pocs/zeroth-playground/playground/docs/documentation.tsx new file mode 100644 index 00000000..651a60a7 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/documentation.tsx @@ -0,0 +1,23 @@ +import { exampleById } from "../../examples/catalog"; +import { EXAMPLE_PAGES } from "./example-pages"; +import { PAGE_COMPONENTS } from "./page-components"; + +/** + * The example's page: the net it uses and the feature it tackles, from its + * metadata, over the page itself. + */ +export const Documentation: React.FC<{ exampleId: string }> = ({ exampleId }) => { + const example = exampleById(exampleId); + const Page = EXAMPLE_PAGES[exampleId]; + return ( +
+ {example === undefined ? null : ( + <> +

{example.title}

+

{example.feature}

+ + )} + {Page === undefined ?

This example has no page.

: } +
+ ); +}; diff --git a/pocs/zeroth-playground/playground/docs/example-pages.test.tsx b/pocs/zeroth-playground/playground/docs/example-pages.test.tsx new file mode 100644 index 00000000..0983670f --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/example-pages.test.tsx @@ -0,0 +1,38 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import CompilerGuide from "../compiler-view/compiler-guide.mdx"; +import { EXAMPLE_PAGES } from "./example-pages"; +import { PAGE_COMPONENTS } from "./page-components"; + +/** The owners an open question can name, as `OpenQuestion` types them; MDX is not type-checked. */ +const OWNER = /^(HASH|Zeroth|HASH and Zeroth)$/u; + +/** The owners in a page's markup that `OpenQuestion` does not allow. */ +function strayOwners(html: string): (string | undefined)[] { + const owners = Array.from(html.matchAll(/open-question__owner">For ([^<]*) match[1]); + return owners.filter((owner) => !OWNER.test(owner ?? "")); +} + +describe("the example pages", () => { + for (const [id, Page] of Object.entries(EXAMPLE_PAGES)) { + it(`render ${id}'s page with the components the documentation view passes`, () => { + // GIVEN the page and the components the documentation view passes it + // WHEN it renders, which throws on a component it uses but neither imports nor is passed + const html = renderToStaticMarkup(); + // THEN each open question names who can settle it + expect(strayOwners(html)).toEqual([]); + }); + } +}); + +describe("the Compiler guide", () => { + it("renders with the components the documentation view passes", () => { + // GIVEN the guide and the components the documentation view passes a page + // WHEN it renders + const html = renderToStaticMarkup(); + // THEN it has an open question, and each one names who can settle it + expect(html).toContain("open-question__owner"); + expect(strayOwners(html)).toEqual([]); + }); +}); diff --git a/pocs/zeroth-playground/playground/docs/example-pages.ts b/pocs/zeroth-playground/playground/docs/example-pages.ts new file mode 100644 index 00000000..46ba3bc0 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/example-pages.ts @@ -0,0 +1,10 @@ +import { folderOf } from "../../examples/catalog"; + +import type { MDXContent } from "mdx/types"; + +/** Each example folder's `page.mdx`, keyed by the folder, as the catalog is. */ +export const EXAMPLE_PAGES: Readonly> = Object.fromEntries( + Object.entries( + import.meta.glob("../../examples/*/page.mdx", { eager: true, import: "default" }), + ).map(([path, page]) => [folderOf(path), page]), +); diff --git a/pocs/zeroth-playground/playground/docs/figures/figure.css b/pocs/zeroth-playground/playground/docs/figures/figure.css new file mode 100644 index 00000000..61737990 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/figures/figure.css @@ -0,0 +1,16 @@ +/* -- The figure frame every page shares: the drawing, then its caption ----- */ + +/* Each drawing sets its own CSS beside it, in its example's components/ folder. + The frame is a size container, so a drawing can change its layout with the + width it gets: the docs panel is about 200 px wide in a 1000 px window. */ +.figure { + margin: 0.75rem 0 1rem; + container-type: inline-size; +} + +.figure__caption { + margin-top: 0.6rem; + color: var(--sw-ink-2); + font-size: 0.75rem; + line-height: 1.45; +} diff --git a/pocs/zeroth-playground/playground/docs/figures/figure.tsx b/pocs/zeroth-playground/playground/docs/figures/figure.tsx new file mode 100644 index 00000000..af89ca30 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/figures/figure.tsx @@ -0,0 +1,18 @@ +import "./figure.css"; + +type FigureProps = { + /** What the figure shows, read under it and by screen readers. */ + caption: string; + /** The drawing, from the example's `components/` folder. */ + children: React.ReactNode; +}; + +/** The frame every figure on a page shares: the drawing, then its caption. */ +export const Figure: React.FC = ({ caption, children }) => { + return ( +
+ {children} +
{caption}
+
+ ); +}; diff --git a/pocs/zeroth-playground/playground/docs/open-question.tsx b/pocs/zeroth-playground/playground/docs/open-question.tsx new file mode 100644 index 00000000..632bde9c --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/open-question.tsx @@ -0,0 +1,16 @@ +type OpenQuestionProps = { + /** Who can settle it. */ + owner: "HASH" | "Zeroth" | "HASH and Zeroth"; + /** The question, and why it matters for this net. */ + children: React.ReactNode; +}; + +/** A question the page leaves open, set apart in amber with whoever can answer it. */ +export const OpenQuestion: React.FC = ({ owner, children }) => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/docs/page-components.ts b/pocs/zeroth-playground/playground/docs/page-components.ts new file mode 100644 index 00000000..b16c8c3b --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/page-components.ts @@ -0,0 +1,12 @@ +import { CodeBlock } from "./code-block"; +import { Figure } from "./figures/figure"; +import { OpenQuestion } from "./open-question"; + +import type { MDXComponents } from "mdx/types"; + +/** + * The components every page uses without importing them. The documentation + * view passes them to each page, so the examples import nothing from the + * playground. `pre` renders the page's fenced code. + */ +export const PAGE_COMPONENTS = { Figure, OpenQuestion, pre: CodeBlock } satisfies MDXComponents; diff --git a/pocs/zeroth-playground/playground/editor/line-decorations.ts b/pocs/zeroth-playground/playground/editor/line-decorations.ts new file mode 100644 index 00000000..d45de8a0 --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/line-decorations.ts @@ -0,0 +1,36 @@ +import type { editor } from "monaco-editor/editor/editor.api.js"; + +/** A whole-line mark: lit by a hover on the other side, or flagged by a diagnostic. */ +export type LineDecoration = { + startLine: number; + endLine: number; + kind: "lit" | "error" | "warning"; +}; + +const CLASS_NAMES: Record = { + lit: { isWholeLine: true, className: "line-lit" }, + error: { + isWholeLine: true, + className: "line-error", + linesDecorationsClassName: "line-error-mark", + }, + warning: { + isWholeLine: true, + className: "line-warning", + linesDecorationsClassName: "line-warning-mark", + }, +}; + +export function toModelDecorations( + decorations: readonly LineDecoration[], +): editor.IModelDeltaDecoration[] { + return decorations.map((decoration) => ({ + range: { + startLineNumber: decoration.startLine, + startColumn: 1, + endLineNumber: decoration.endLine, + endColumn: 1, + }, + options: CLASS_NAMES[decoration.kind], + })); +} diff --git a/pocs/zeroth-playground/playground/editor/monaco-editor.tsx b/pocs/zeroth-playground/playground/editor/monaco-editor.tsx new file mode 100644 index 00000000..1b0ab156 --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/monaco-editor.tsx @@ -0,0 +1,200 @@ +import { useEffect, useEffectEvent, useRef, useState } from "react"; + +import { type LineDecoration, toModelDecorations } from "./line-decorations"; +import { monaco } from "./monaco"; +import { bindTrace, unbindTrace } from "./provenance-hover"; +import { SWISS_THEME } from "./swiss-theme"; + +import type { Trace } from "../../compiler"; +import type { editor } from "monaco-editor/editor/editor.api.js"; +import type { PETRI_NET_IR_LANGUAGE } from "./petri-net-ir-language"; + +/** The editor instance a parent can reach through `editorRef`, to reveal a line say. */ +export type CodeEditor = editor.IStandaloneCodeEditor; + +type MonacoEditorProps = { + /** Names the model; one model per path, kept while the editor remounts. */ + path: string; + /** The IR's grammar, or Monaco's Python. */ + language: typeof PETRI_NET_IR_LANGUAGE | "python"; + value: string; + readOnly?: boolean; + /** Pins the enclosing entries at the top while scrolling; from indentation, as the IR has no symbols. */ + stickyScroll?: boolean; + onChange?: (value: string) => void; + /** The trace over `value`, read by the hover; none means no hover. */ + trace?: Trace; + decorations?: readonly LineDecoration[]; + /** The line under the pointer, `null` when it leaves the text. */ + onHoverLine?: (line: number | null) => void; + /** Holds the editor while it is mounted, for imperative calls from event handlers. */ + editorRef?: React.RefObject; +}; + +const OPTIONS: editor.IStandaloneEditorConstructionOptions = { + theme: SWISS_THEME, + automaticLayout: true, + fontFamily: "JetBrains Mono Variable", + fontSize: 12, + lineHeight: 18, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + renderLineHighlight: "none", + lineNumbersMinChars: 3, + lineDecorationsWidth: 6, + glyphMargin: false, + folding: true, + // Expanded chevrons rest faint and come up while the pointer is over the gutter; see theme.css. + showFoldingControls: "mouseover", + wordBasedSuggestions: "off", + quickSuggestions: { other: true, comments: false, strings: true }, + suggest: { showStatusBar: false, preview: false }, + fixedOverflowWidgets: true, + padding: { top: 8, bottom: 8 }, + scrollbar: { verticalScrollbarSize: 8, horizontalScrollbarSize: 8 }, + overviewRulerLanes: 0, + overviewRulerBorder: false, + hideCursorInOverviewRuler: true, + occurrencesHighlight: "off", + selectionHighlight: false, + matchBrackets: "never", + guides: { indentation: true }, + tabSize: 2, + insertSpaces: true, + editContext: false, +}; + +const NO_DECORATIONS: readonly LineDecoration[] = []; + +/** Each path's scroll, cursor and folds, kept while its editor is disposed, as when a view is hidden. */ +const viewStates = new Map(); + +function uriOf(path: string) { + return monaco.Uri.from({ scheme: "playground", path: `/${path}` }); +} + +/** The model for a path, created on first use with the value it will show. */ +function modelFor(path: string, language: string, value: string): editor.ITextModel { + const uri = uriOf(path); + const existing = monaco.editor.getModel(uri); + if (existing !== null) { + monaco.editor.setModelLanguage(existing, language); + return existing; + } + return monaco.editor.createModel(value, language, uri); +} + +/** Replaces the model's text as one edit, so undo keeps working across a rewrite from outside. */ +function setModelValue(model: editor.ITextModel, value: string): void { + if (model.getValue() === value) { + return; + } + model.pushEditOperations( + [], + [{ range: model.getFullModelRange(), text: value }], + () => null, + ); +} + +type Instance = { + editor: editor.IStandaloneCodeEditor; + decorations: editor.IEditorDecorationsCollection; +}; + +/** + * A Monaco editor over one model: controlled by `value`, reporting edits + * through `onChange` and the hovered line through `onHoverLine`, showing the + * decorations it is given, with the trace bound for the hover card. Its + * effects each sync one thing React does not own: the instance, the model's + * text, the decorations, the trace the hover reads. + */ +export const MonacoEditor: React.FC = ({ + path, + language, + value, + readOnly = false, + stickyScroll = false, + onChange, + trace, + decorations = NO_DECORATIONS, + onHoverLine, + editorRef, +}) => { + const [container, setContainer] = useState(null); + const instanceRef = useRef(null); + // Monaco's listeners call these with the latest props, without recreating the editor. Monaco + // also reports the text the value effect writes, and a text equal to `value` is no edit. + const emitChange = useEffectEvent((text: string) => { + if (text !== value) { + onChange?.(text); + } + }); + const emitHoverLine = useEffectEvent((line: number | null) => onHoverLine?.(line)); + + useEffect(() => { + if (container === null) { + return; + } + // The value is read once, to create the model; later values flow through the effect below. + const instance = monaco.editor.create(container, { + ...OPTIONS, + model: modelFor(path, language, value), + readOnly, + domReadOnly: readOnly, + stickyScroll: { enabled: stickyScroll, defaultModel: "indentationModel", maxLineCount: 4 }, + }); + instance.restoreViewState(viewStates.get(path) ?? null); + instanceRef.current = { editor: instance, decorations: instance.createDecorationsCollection() }; + if (editorRef !== undefined) { + editorRef.current = instance; + } + let hoveredLine: number | null = null; + function hover(line: number | null) { + if (line !== hoveredLine) { + hoveredLine = line; + emitHoverLine(line); + } + } + const subscriptions = [ + instance.onDidChangeModelContent(() => emitChange(instance.getValue())), + instance.onMouseMove((event) => hover(event.target.position?.lineNumber ?? null)), + instance.onMouseLeave(() => hover(null)), + ]; + return () => { + for (const subscription of subscriptions) { + subscription.dispose(); + } + hover(null); + viewStates.set(path, instance.saveViewState()); + instance.dispose(); + instanceRef.current = null; + if (editorRef !== undefined) { + editorRef.current = null; + } + }; + }, [container, path, language, readOnly, stickyScroll, editorRef]); + + useEffect(() => { + const model = monaco.editor.getModel(uriOf(path)); + if (model !== null) { + setModelValue(model, value); + } + }, [path, value]); + + // The instance effect's dependencies too, so the decorations land on each new editor. + useEffect(() => { + instanceRef.current?.decorations.set(toModelDecorations(decorations)); + }, [decorations, container, path, language, readOnly, stickyScroll]); + + useEffect(() => { + const key = uriOf(path).toString(); + if (trace === undefined) { + unbindTrace(key); + return; + } + bindTrace(key, { trace, text: value }); + return () => unbindTrace(key); + }, [path, trace, value]); + + return
; +}; diff --git a/pocs/zeroth-playground/playground/editor/monaco-languages.d.ts b/pocs/zeroth-playground/playground/editor/monaco-languages.d.ts new file mode 100644 index 00000000..2857a94e --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/monaco-languages.d.ts @@ -0,0 +1,6 @@ +declare module "monaco-editor/languages/definitions/yaml/yaml.js" { + import type { languages } from "monaco-editor/editor/editor.api.js"; + + export const conf: languages.LanguageConfiguration; + export const language: languages.IMonarchLanguage; +} diff --git a/pocs/zeroth-playground/playground/editor/monaco.ts b/pocs/zeroth-playground/playground/editor/monaco.ts new file mode 100644 index 00000000..6dc523bb --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/monaco.ts @@ -0,0 +1,41 @@ +/** + * Monaco, bundled from npm: the tree-shakeable `editor.api` entry plus the + * features and grammars the two editors use, never the CDN. The editor + * worker is inlined by Vite so the single file needs no worker file beside + * it; neither grammar needs a language worker. + */ +import * as monaco from "monaco-editor/editor/editor.api.js"; +import EditorWorker from "monaco-editor/editor/editor.worker.js?worker&inline"; +// The suggest feature entry loads inline completions alone; the widget is its controller. +import "monaco-editor/editor/contrib/suggest/browser/suggestController.js"; +import "monaco-editor/features/folding/register.js"; +import "monaco-editor/features/hover/register.js"; +import "monaco-editor/features/stickyScroll/register.js"; +import "monaco-editor/languages/definitions/python/register.js"; + +import { registerPetriNetIrLanguage } from "./petri-net-ir-language"; +import { registerProvenanceHover } from "./provenance-hover"; +import { defineSwissTheme } from "./swiss-theme"; + +globalThis.MonacoEnvironment = { + getWorker: () => new EditorWorker(), +}; + +declare global { + /** The providers the last run of this module registered on the shared Monaco. */ + var playgroundMonacoProviders: monaco.IDisposable[] | undefined; +} + +// A hot update in development runs this module again against the same Monaco: dispose the +// providers of the previous run, or every hover card and completion list would show twice. +for (const provider of globalThis.playgroundMonacoProviders ?? []) { + provider.dispose(); +} +defineSwissTheme(monaco); +globalThis.playgroundMonacoProviders = [ + ...registerPetriNetIrLanguage(monaco), + ...registerProvenanceHover(monaco), +]; + +export { monaco }; +export type Monaco = typeof monaco; diff --git a/pocs/zeroth-playground/playground/editor/petri-net-ir-language.ts b/pocs/zeroth-playground/playground/editor/petri-net-ir-language.ts new file mode 100644 index 00000000..337d8ccc --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/petri-net-ir-language.ts @@ -0,0 +1,87 @@ +import { + conf as yamlConfiguration, + language as yamlLanguage, +} from "monaco-editor/languages/definitions/yaml/yaml.js"; + +import { completionsAt } from "./petri-net-ir-language/completions"; + +import type { IDisposable, languages } from "monaco-editor/editor/editor.api.js"; +import type { Monaco } from "./monaco"; + +/** + * The `petri-net-ir` language: Monaco's YAML Monarch tokenizer and + * configuration under the IR's own id, plus a completion provider that knows + * the IR's keys per nesting level and the names declared above the cursor. + * The hover comes from the provenance provider, registered for the id too. + */ + +export const PETRI_NET_IR_LANGUAGE = "petri-net-ir"; + +const tokenizer: languages.IMonarchLanguage = { + ...yamlLanguage, + tokenPostfix: ".petri-net-ir", +}; + +function completionKind( + monaco: Monaco, + kind: "key" | "value" | "name", +): languages.CompletionItemKind { + switch (kind) { + case "key": + return monaco.languages.CompletionItemKind.Property; + case "value": + return monaco.languages.CompletionItemKind.EnumMember; + case "name": + return monaco.languages.CompletionItemKind.Variable; + } +} + +function completionProvider(monaco: Monaco): languages.CompletionItemProvider { + return { + triggerCharacters: [" "], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const suggestions = completionsAt( + model.getValue(), + position.lineNumber, + position.column, + ).map( + // The line on each completion is prose: a description beside the label, and the + // documentation of the expanded panel. Monaco's `detail` is for a short signature, + // which it breaks mid-word in a header sized for one line. + (completion, index): languages.CompletionItem => ({ + label: + completion.detail === undefined + ? completion.label + : { label: completion.label, description: completion.detail }, + insertText: completion.insertText, + kind: completionKind(monaco, completion.kind), + range, + sortText: String(index).padStart(3, "0"), + ...(completion.detail === undefined ? {} : { documentation: completion.detail }), + }), + ); + return { suggestions }; + }, + }; +} + +/** Registers the language and its providers; the disposables take the providers back. */ +export function registerPetriNetIrLanguage(monaco: Monaco): IDisposable[] { + monaco.languages.register({ + id: PETRI_NET_IR_LANGUAGE, + extensions: [".pn.yaml"], + aliases: ["Petri net IR"], + }); + return [ + monaco.languages.setLanguageConfiguration(PETRI_NET_IR_LANGUAGE, yamlConfiguration), + monaco.languages.setMonarchTokensProvider(PETRI_NET_IR_LANGUAGE, tokenizer), + monaco.languages.registerCompletionItemProvider(PETRI_NET_IR_LANGUAGE, completionProvider(monaco)), + ]; +} diff --git a/pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.test.ts b/pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.test.ts new file mode 100644 index 00000000..9e29bd58 --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { completionsAt, cursorContext } from "./completions"; + +const DOC = [ + "name: drones", + "kind: mixed", + "", + "colours:", + " Drone:", + " battery: real", + "", + "places:", + " Hangar:", + " colour: Drone", + " Airborne:", + "", + "transitions:", + " Launch:", + " inputs:", + " Hangar:", + " outputs:", + " ", + " ", +].join("\n"); + +function labels(line: number, column: number): string[] { + return completionsAt(DOC, line, column).map((completion) => completion.label); +} + +describe("cursorContext", () => { + it("reads the parent path from the indentation and the names declared above", () => { + // GIVEN the Drones document, the cursor on the empty line under Launch's outputs + // WHEN the context at the cursor is read + const context = cursorContext(DOC, 18, 7); + // THEN it sits under the outputs, after no key, with the places and colours declared above + expect(context.parentPath).toEqual(["transitions", "Launch", "outputs"]); + expect(context.valueOf).toBeNull(); + expect(context.places).toEqual(["Hangar", "Airborne"]); + expect(context.colours).toEqual(["Drone"]); + }); + + it("names the key whose value the cursor sits after", () => { + // GIVEN the cursor after Hangar's "colour:" + // WHEN the context at the cursor is read + const context = cursorContext(DOC, 10, 13); + // THEN it is the value of colour, under the place + expect(context).toMatchObject({ + parentPath: ["places", "Hangar"], + valueOf: "colour", + }); + }); +}); + +describe("completionsAt", () => { + it("offers the section keys at the top level", () => { + // GIVEN the cursor on a blank line at the top level + // WHEN completions are asked for + const offered = labels(3, 1); + // THEN every section is offered, in the schema's order + expect(offered).toEqual([ + "name", + "description", + "kind", + "colours", + "dynamics", + "places", + "marking", + "transitions", + ]); + }); + + it("offers a place's, a transition's and an arc's keys by nesting level", () => { + // GIVEN the cursor inside a transition, a place and an arc + // WHEN completions are asked for at each + const transition = labels(19, 5); + const place = completionsAt("places:\n A:\n ", 3, 5).map((c) => c.label); + const arc = completionsAt("transitions:\n Go:\n inputs:\n A:\n ", 5, 9).map( + (c) => c.label, + ); + // THEN each offers the keys of its own level + expect(transition).toEqual(["inputs", "outputs", "guard", "rate", "kernel", "controllable"]); + expect(place).toEqual(["capacity", "colour", "dynamics"]); + expect(arc).toEqual(["weight", "kind"]); + }); + + it("offers the places declared above the cursor under an arc list and the marking", () => { + // GIVEN the cursor under Launch's outputs, and under a marking + // WHEN completions are asked for at each + const outputs = completionsAt(DOC, 18, 7); + const marking = completionsAt("places:\n A:\n B:\n\nmarking:\n ", 6, 3).map((c) => c.label); + // THEN both offer the declared places, and a completion inserts the place as a key + expect(outputs.map((completion) => completion.label)).toEqual(["Hangar", "Airborne"]); + expect(outputs[0]?.insertText).toBe("Hangar:"); + expect(marking).toEqual(["A", "B"]); + }); + + it("offers a key's values after its colon", () => { + // GIVEN the cursor after a place's colour, the net's kind and an arc's kind + // WHEN completions are asked for at each + const colour = labels(10, 13); + const netKind = completionsAt("kind: ", 1, 7).map((c) => c.label); + const arcKind = completionsAt("transitions:\n Go:\n inputs:\n A:\n kind: ", 5, 15).map( + (c) => c.label, + ); + // THEN each offers the values its key takes: the declared colours, or the schema's values + expect(colour).toEqual(["Drone"]); + expect(netKind).toEqual(["plain", "stochastic", "mixed"]); + expect(arcKind).toEqual(["read", "inhibitor"]); + }); +}); diff --git a/pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.ts b/pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.ts new file mode 100644 index 00000000..272b94be --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/petri-net-ir-language/completions.ts @@ -0,0 +1,226 @@ +/** + * What the IR language offers at a cursor: the keys a nesting level takes, + * the values a key takes, and the places, colours and dynamics declared + * above the cursor where a key names one. Pure over the text, so it is + * tested without an editor. The key tables are typed by the schema's types, + * so a key added to or dropped from the IR fails the type check here. + */ + +import type { PetriNetIr, PetriNetIrArc, PetriNetIrPlace, PetriNetIrTransition } from "../../../compiler"; + +export type Completion = { + label: string; + insertText: string; + kind: "key" | "value" | "name"; + detail?: string; +}; + +export type CursorContext = { + /** The path of the entry the cursor's line belongs to, `["transitions", "Go"]` for a line under Go. */ + parentPath: string[]; + /** The key on the cursor's line when the cursor sits after its colon. */ + valueOf: string | null; + places: string[]; + colours: string[]; + dynamics: string[]; +}; + +const KEY_LINE = /^( *)(?:- )?([A-Za-z_$][\w-]*):(?: (.*)|$)/u; + +const SECTION_KEYS: Record = { + name: "The net's name, an identifier the module is named from", + description: "Display text", + kind: "plain, stochastic or mixed", + colours: "Token colours, each with its attributes", + dynamics: "Differential equations, each with the colour it moves", + places: "Each place; a bare key is a plain unbounded place", + marking: "The tokens each place starts with", + transitions: "Each transition, in sweep order", +}; + +const PLACE_KEYS: Record, string> = { + capacity: "Maximum tokens the place holds", + colour: "The colour of the tokens it holds", + dynamics: "The equation that moves its tokens between steps", +}; + +const TRANSITION_KEYS: Record = { + inputs: "Input arcs keyed by place, in binding order", + outputs: "Output arcs keyed by place", + guard: "A predicate over the input tokens, as code", + rate: "Mean firings per time unit: a number, or code over the tokens", + kernel: "Code writing the produced tokens' attributes", + controllable: "The firing is a controller's choice", +}; + +const ARC_KEYS: Record, string> = { + weight: "Tokens the arc moves; absent means one", + kind: "read needs the tokens and leaves them; inhibitor needs fewer", +}; + +const DYNAMICS_KEYS: Record[string], string> = { + colour: "The colour whose tokens it moves", + code: "return tokens.map((token) => ({ attribute: derivative }))", +}; + +const ATTRIBUTE_TYPES = ["real", "integer", "boolean", "uuid", "string"]; + +function keys(record: Record): Completion[] { + return Object.entries(record).map(([label, detail]) => ({ + label, + insertText: `${label}: `, + kind: "key", + detail, + })); +} + +function values(labels: readonly string[]): Completion[] { + return labels.map((label) => ({ label, insertText: label, kind: "value" })); +} + +function names(labels: readonly string[], detail: string): Completion[] { + return labels.map((label) => ({ label, insertText: `${label}:`, kind: "name", detail })); +} + +/** The context of the cursor from the lines above it and the line it is on. */ +export function cursorContext(text: string, lineNumber: number, column: number): CursorContext { + const lines = text.split("\n"); + const current = lines[lineNumber - 1] ?? ""; + const beforeCursor = current.slice(0, column - 1); + const indent = /^ */u.exec(beforeCursor)?.[0].length ?? 0; + const stack: { indent: number; path: string[] }[] = []; + const places: string[] = []; + const colours: string[] = []; + const dynamics: string[] = []; + let blockIndent: number | null = null; + for (const line of lines.slice(0, lineNumber - 1)) { + if (blockIndent !== null) { + const lineIndent = /^ */u.exec(line)?.[0].length ?? 0; + if (line.trim() === "" || lineIndent > blockIndent) { + continue; + } + blockIndent = null; + } + const match = KEY_LINE.exec(line); + if (match === null) { + continue; + } + const lineIndent = match[1]?.length ?? 0; + const key = match[2] ?? ""; + if (/^[|>][-+]?$/u.test(match[3] ?? "")) { + blockIndent = lineIndent; + } + while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? -1) >= lineIndent) { + stack.pop(); + } + const path = [...(stack[stack.length - 1]?.path ?? []), key]; + stack.push({ indent: lineIndent, path }); + if (path.length === 2) { + const section = path[0]; + if (section === "places") { + places.push(key); + } else if (section === "colours") { + colours.push(key); + } else if (section === "dynamics") { + dynamics.push(key); + } + } + } + while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? -1) >= indent) { + stack.pop(); + } + const parentPath = stack[stack.length - 1]?.path ?? []; + const ownKey = /^ *(?:- )?([A-Za-z_$][\w-]*):(?: |$)/u.exec(beforeCursor)?.[1]; + return { + parentPath, + valueOf: ownKey ?? null, + places, + colours, + dynamics, + }; +} + +function valueCompletions(path: string[], context: CursorContext): Completion[] { + const [section, , field, arcPlace, arcField] = path; + if (path.length === 1 && section === "kind") { + return values(["plain", "stochastic", "mixed"]); + } + if (section === "places" && path.length === 3) { + if (field === "colour") { + return values(context.colours); + } + if (field === "dynamics") { + return values(context.dynamics); + } + } + if (section === "dynamics" && path.length === 3 && field === "colour") { + return values(context.colours); + } + if (section === "colours" && path.length === 3) { + return values(ATTRIBUTE_TYPES); + } + if (section === "transitions" && path.length === 3 && field === "controllable") { + return values(["true"]); + } + if ( + section === "transitions" && + path.length === 5 && + field === "inputs" && + arcPlace !== undefined && + arcField === "kind" + ) { + return values(["read", "inhibitor"]); + } + return []; +} + +function keyCompletions(context: CursorContext): Completion[] { + const { parentPath, places } = context; + const [section, , field] = parentPath; + switch (parentPath.length) { + case 0: + return keys(SECTION_KEYS); + case 1: + if (section === "marking") { + return names(places, "A place declared above"); + } + return []; + case 2: + if (section === "places") { + return keys(PLACE_KEYS); + } + if (section === "transitions") { + return keys(TRANSITION_KEYS); + } + if (section === "dynamics") { + return keys(DYNAMICS_KEYS); + } + return []; + case 3: + if (section === "transitions" && (field === "inputs" || field === "outputs")) { + return names(places, "A place declared above"); + } + if (section === "colours") { + return [{ label: "enum", insertText: "enum:", kind: "key", detail: "A closed set of string values" }]; + } + return []; + case 4: + if (section === "transitions" && field === "inputs") { + return keys(ARC_KEYS); + } + if (section === "transitions" && field === "outputs") { + return keys({ weight: ARC_KEYS.weight }); + } + return []; + default: + return []; + } +} + +/** The completions at a cursor: values when it sits after a key's colon, keys or names otherwise. */ +export function completionsAt(text: string, lineNumber: number, column: number): Completion[] { + const context = cursorContext(text, lineNumber, column); + return context.valueOf === null + ? keyCompletions(context) + : valueCompletions([...context.parentPath, context.valueOf], context); +} diff --git a/pocs/zeroth-playground/playground/editor/provenance-hover.ts b/pocs/zeroth-playground/playground/editor/provenance-hover.ts new file mode 100644 index 00000000..e38abbc6 --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/provenance-hover.ts @@ -0,0 +1,79 @@ +import { itemLabel, provenanceAt } from "../../compiler"; +import { PETRI_NET_IR_LANGUAGE } from "./petri-net-ir-language"; + +import type { Provenance, Trace } from "../../compiler"; +import type { IDisposable, editor, languages } from "monaco-editor/editor/editor.api.js"; +import type { Monaco } from "./monaco"; + +/** + * The hover behind both editors: a provider that reads the trace bound to + * the model under the pointer and shows the record of the innermost range + * holding the line. + */ + +/** Monaco's own id for the grammar of the module editor. */ +const PYTHON_LANGUAGE = "python"; + +type TraceBinding = { + trace: Trace; + /** The text the trace was built for; a model showing other text gets no hover. */ + text: string; +}; + +// One binding per model, read by the provider at hover time. +const bindings = new Map(); + +export function bindTrace(modelUri: string, binding: TraceBinding): void { + bindings.set(modelUri, binding); +} + +export function unbindTrace(modelUri: string): void { + bindings.delete(modelUri); +} + +function traceOf(model: editor.ITextModel): Trace | null { + const binding = bindings.get(model.uri.toString()); + return binding === undefined || binding.text !== model.getValue() ? null : binding.trace; +} + +/** The hover card's markdown: what, why, then where it comes from. */ +function provenanceMarkdown(provenance: Provenance): string { + const lines = [`**${provenance.what}**`]; + if (provenance.why !== undefined && provenance.why !== "") { + lines.push("", provenance.why); + } + const sources = [ + ...(provenance.ir === undefined ? [] : [`IR \`${provenance.ir}\``]), + ...(provenance.source === undefined ? [] : [itemLabel(provenance.source)]), + ]; + if (sources.length > 0) { + lines.push("", sources.join(" · ")); + } + return lines.join("\n"); +} + +const hoverProvider: languages.HoverProvider = { + provideHover: (model, position) => { + const trace = traceOf(model); + const provenance = trace === null ? null : provenanceAt(trace, position.lineNumber); + if (provenance === null) { + return null; + } + return { + range: { + startLineNumber: position.lineNumber, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: model.getLineMaxColumn(position.lineNumber), + }, + contents: [{ value: provenanceMarkdown(provenance) }], + }; + }, +}; + +/** Registers the hover provider for both grammars; the disposables take it back. */ +export function registerProvenanceHover(monaco: Monaco): IDisposable[] { + return [PETRI_NET_IR_LANGUAGE, PYTHON_LANGUAGE].map((language) => + monaco.languages.registerHoverProvider(language, hoverProvider), + ); +} diff --git a/pocs/zeroth-playground/playground/editor/swiss-theme.ts b/pocs/zeroth-playground/playground/editor/swiss-theme.ts new file mode 100644 index 00000000..ab4e5dcd --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/swiss-theme.ts @@ -0,0 +1,70 @@ +import type { Monaco } from "./monaco"; + +export const SWISS_THEME = "swiss"; + +/** `vs` in the swiss palette: ink on paper, the accent for keywords and numbers, hairlines for the gutter. */ +export function defineSwissTheme(monaco: Monaco): void { + monaco.editor.defineTheme(SWISS_THEME, { + base: "vs", + inherit: true, + rules: [ + { token: "", foreground: "0f1115" }, + { token: "type", foreground: "0f1115" }, + { token: "string", foreground: "565b66" }, + { token: "number", foreground: "0b5fff" }, + { token: "keyword", foreground: "0b5fff" }, + { token: "operators", foreground: "565b66" }, + { token: "comment", foreground: "565b66", fontStyle: "italic" }, + { token: "delimiter", foreground: "565b66" }, + { token: "identifier", foreground: "0f1115" }, + { token: "tag", foreground: "565b66" }, + ], + colors: { + "editor.background": "#f7f8f9", + "editor.foreground": "#0f1115", + "editorLineNumber.foreground": "#c3c8cf", + "editorLineNumber.activeForeground": "#565b66", + "editor.selectionBackground": "#c9dbff", + "editor.inactiveSelectionBackground": "#dde1e6", + "editorCursor.foreground": "#0b5fff", + "editorIndentGuide.background1": "#dde1e6", + "editorIndentGuide.activeBackground1": "#c3c8cf", + "editorWidget.background": "#f7f8f9", + "editorWidget.border": "#c3c8cf", + // Hover cards read as ink on paper inverted: paper text on ink, code in a grey chip. + "editorHoverWidget.background": "#0f1115", + "editorHoverWidget.foreground": "#f7f8f9", + "editorHoverWidget.border": "#0f1115", + "editorHoverWidget.statusBarBackground": "#0f1115", + "textCodeBlock.background": "#2b2f36", + "textPreformat.foreground": "#f7f8f9", + "textPreformat.background": "#2b2f36", + "textLink.foreground": "#7db6ff", + // Completions: ink on white, a step lighter than the editor, behind a hairline; the + // selected row a paper-grey step down. Without the foregrounds, `vs` writes the + // selected row in white, meant for its blue selection. + "editorSuggestWidget.background": "#ffffff", + "editorSuggestWidget.border": "#c3c8cf", + "editorSuggestWidget.foreground": "#0f1115", + "editorSuggestWidget.selectedBackground": "#e6e9ed", + "editorSuggestWidget.selectedForeground": "#0f1115", + "editorSuggestWidget.selectedIconForeground": "#565b66", + "editorSuggestWidget.highlightForeground": "#0b5fff", + "editorSuggestWidget.focusHighlightForeground": "#0b5fff", + "list.hoverBackground": "#f2f4f6", + "symbolIcon.propertyForeground": "#565b66", + "symbolIcon.enumeratorMemberForeground": "#565b66", + "symbolIcon.variableForeground": "#565b66", + "focusBorder": "#0b5fff", + "scrollbarSlider.background": "#c3c8cf80", + "scrollbarSlider.hoverBackground": "#c3c8cf", + "scrollbarSlider.activeBackground": "#565b66", + "editorGutter.background": "#f7f8f9", + "editorStickyScroll.background": "#f7f8f9", + "editorStickyScroll.border": "#dde1e6", + "editorStickyScroll.shadow": "#00000000", + "editorStickyScrollHover.background": "#edeff1", + "editorGutter.foldingControlForeground": "#565b66", + }, + }); +} diff --git a/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx b/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx new file mode 100644 index 00000000..c650b4ad --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; + +import { NOT_USED, OPTION_SECTIONS, type OptionLabels } from "../options/option-labels"; + +import type { OptionName, OptionState } from "../../compiler"; + +function sectionId(title: string): string { + return `options-${title.toLowerCase().replaceAll(" ", "-")}`; +} + +type CompilerOptionsPanelProps = { + states: readonly OptionState[]; + onChange: (name: OptionName, value: string) => void; +}; + +type OptionRowProps = { + labels: OptionLabels; + state: OptionState; + onChange: (value: string) => void; +}; + +const OptionRow: React.FC = ({ labels, state, onChange }) => { + // What the field holds while it has focus. "1." or an empty field is no + // value yet, and showing the stored value instead would undo the keystroke. + const [draft, setDraft] = useState(null); + const draftNumber = Number(draft); + const invalid = draft !== null && !(Number.isFinite(draftNumber) && draftNumber > 0); + const disabled = state.notApplicable !== undefined; + const id = `option-${labels.name}`; + return ( +
+ + {labels.choices === undefined ? ( + { + setDraft(event.target.value); + onChange(event.target.value); + }} + onBlur={() => setDraft(null)} + /> + ) : ( + + )} + {/* Off for this net, the reason replaces the hint. */} +

+ {state.notApplicable ?? labels.hint} +

+
+ ); +}; + +/** + * The compiler options, grouped by what they decide. Each option says what it + * does; one that only matters through another sits nested under it; one that + * does not apply to the net is greyed, with the reason in place of its hint. + */ +export const CompilerOptionsPanel: React.FC = ({ states, onChange }) => ( +
+ {OPTION_SECTIONS.map((section) => ( +
+

+ {section.title} +

+

{section.about}

+ {section.options.map((labels) => { + const state = states.find((candidate) => candidate.name === labels.name); + return state === undefined ? null : ( + onChange(labels.name, value)} + /> + ); + })} +
+ ))} +
+); diff --git a/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx b/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx new file mode 100644 index 00000000..85d697c2 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx @@ -0,0 +1,62 @@ +import type { Diagnostic } from "../../compiler"; + +type DiagnosticsListProps = { + errors: readonly Diagnostic[]; + warnings: readonly Diagnostic[]; + /** Reveals the IR line a diagnostic points at. */ + onReveal?: (line: number) => void; +}; + +type DiagnosticLineProps = { + diagnostic: Diagnostic; + severity: "error" | "warning"; + onReveal?: (line: number) => void; +}; + +const DiagnosticLine: React.FC = ({ diagnostic, severity, onReveal }) => { + const { line } = diagnostic; + const where = + diagnostic.item.name === "" ? null : ( + {diagnostic.item.name} + ); + return ( +
  • + {diagnostic.code} + {diagnostic.message} + {where} + {line === undefined ? null : ( + + )} +
  • + ); +}; + +/** Errors then warnings, one line each, under the module. Nothing when both are empty. */ +export const DiagnosticsList: React.FC = ({ errors, warnings, onReveal }) => { + return errors.length === 0 && warnings.length === 0 ? null : ( +
      + {errors.map((diagnostic, index) => ( + + ))} + {warnings.map((diagnostic, index) => ( + + ))} +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/examples-view/examples-view.tsx b/pocs/zeroth-playground/playground/examples-view/examples-view.tsx new file mode 100644 index 00000000..10a25f59 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/examples-view.tsx @@ -0,0 +1,207 @@ +import { useRef } from "react"; +import { + Cell, + Grid, + Gridline, + Group, + Panel as ResizablePanel, + Separator, + useGridRef, + usePanelRef, +} from "react-resizable-panels"; + +import { optionStates } from "../../compiler"; +import { withIrText, withOptionText } from "../app/document"; +import { Documentation } from "../docs/documentation"; +import { type CodeEditor, MonacoEditor } from "../editor/monaco-editor"; +import { NetPreview } from "../preview/net-preview"; +import { Panel } from "../ui/panel"; +import { RESIZE_TARGET } from "../ui/resize-target"; +import { Switch } from "../ui/switch"; +import { type Foldable, useFolds } from "../ui/use-folds"; +import { CompilerOptionsPanel } from "./compiler-options-panel"; +import { type Hover, hoverAtLine, hoverAtSource, litLines } from "./hover"; +import { toggleEditing } from "./module-edits"; +import { ModuleView } from "./module-view"; + +import type { Compilation, Diagnostic } from "../../compiler"; +import type { Document } from "../app/document"; +import type { LineDecoration } from "../editor/line-decorations"; + +type ExamplesViewProps = { + document: Document; + /** The document compiled, once per render, by the app. */ + compilation: Compilation; + onChange: (document: Document) => void; + /** What the pointer has settled on in the IR, the module or the preview. */ + hover: Hover | null; + onHover: (hover: Hover | null) => void; +}; + +/** + * The panels that fold, by the container that owns them. Each container's + * layout callback reads only its own handles: the grid's tracks are not + * there while Activity has the grid unmounted, and a nested group may report + * its layout before the grid around it has mounted again. + */ +const GRID_FOLDS = ["documentation", "preview"] as const; +const STACK_FOLDS = ["options"] as const; + +/** The IR lines the diagnostics point at. */ +function diagnosticLines( + diagnostics: readonly Diagnostic[], + kind: "error" | "warning", +): LineDecoration[] { + return diagnostics.flatMap((diagnostic) => + diagnostic.line === undefined + ? [] + : [{ startLine: diagnostic.line, endLine: diagnostic.line, kind }], + ); +} + +/** + * The Examples view: the example's page down the left; the IR editor over + * the compiler options, and the emitted module beside them; the net preview + * under both. A hover in the IR, the module or the preview lights what + * belongs with it in the other two. + */ +export const ExamplesView: React.FC = ({ document, compilation, onChange, hover, onHover }) => { + const irEditorRef = useRef(null); + const gridRef = useGridRef(); + const optionsRef = usePanelRef(); + + function trackHandle(key: (typeof GRID_FOLDS)[number]): Foldable | undefined { + return gridRef.current?.getTrackById(key === "documentation" ? "column" : "row", key); + } + + const gridFolds = useFolds(GRID_FOLDS, trackHandle); + const stackFolds = useFolds(STACK_FOLDS, () => optionsRef.current); + const { ir } = compilation; + const optionRows = ir === null ? [] : optionStates(ir, document.options); + const irDecorations = [ + ...litLines("ir", compilation.irTrace, hover), + ...diagnosticLines(compilation.errors, "error"), + ...diagnosticLines(compilation.warnings, "warning"), + ]; + + function revealIrLine(line: number) { + const instance = irEditorRef.current; + instance?.revealLineInCenter(line); + instance?.setPosition({ lineNumber: line, column: 1 }); + instance?.focus(); + } + + return ( + + + + + + + + + + + onChange(withIrText(document, irText))} + trace={compilation.irTrace} + decorations={irDecorations} + onHoverLine={(line) => onHover(hoverAtLine("ir", compilation.irTrace, line))} + editorRef={irEditorRef} + /> + + + + + + { + if (ir !== null) { + onChange(withOptionText(document, ir, name, value)); + } + }} + /> + + + + + + onChange({ ...document, module: toggleEditing(document.module) })} + /> + } + > + onChange({ ...document, module })} + hover={hover} + onHover={onHover} + onRevealIrLine={revealIrLine} + /> + + + + + {ir === null ? ( +

    The net draws once the text parses as an IR.

    + ) : ( + onHover(hoverAtSource(source))} + /> + )} +
    +
    + {/* The documentation column's line spans both rows; the other two cross under the editors. */} + + + +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/examples-view/file-list.tsx b/pocs/zeroth-playground/playground/examples-view/file-list.tsx new file mode 100644 index 00000000..3084e00f --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/file-list.tsx @@ -0,0 +1,24 @@ +type FileListProps = { + paths: readonly string[]; + selected: string; + onSelect: (path: string) => void; +}; + +/** The emitted files, `net.py` first; the one shown is marked. */ +export const FileList: React.FC = ({ paths, selected, onSelect }) => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/examples-view/hover.ts b/pocs/zeroth-playground/playground/examples-view/hover.ts new file mode 100644 index 00000000..1dc3e1b1 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/hover.ts @@ -0,0 +1,40 @@ +import { itemLabel, matchingLines, provenanceAt } from "../../compiler"; + +import type { NetItem, Provenance, Trace } from "../../compiler"; +import type { LineDecoration } from "../editor/line-decorations"; + +/** + * What the pointer is on, shared by the IR, the module and the preview: the + * provenance under it and the view it started in. The other two light the + * lines or the item that belong with it. + */ +export type Hover = { + origin: "ir" | "module" | "preview"; + provenance: Provenance; +}; + +/** The hover a line in a view stands for, or `null` off every traced range. */ +export function hoverAtLine( + origin: Hover["origin"], + trace: Trace, + line: number | null, +): Hover | null { + const provenance = line === null ? null : provenanceAt(trace, line); + return provenance === null ? null : { origin, provenance }; +} + +/** The lines a view lights: those in its trace that match a hover begun in another view. */ +export function litLines( + view: Hover["origin"], + trace: Trace, + hover: Hover | null, +): LineDecoration[] { + return hover === null || hover.origin === view + ? [] + : matchingLines(trace, hover.provenance).map((range) => ({ ...range, kind: "lit" })); +} + +/** The hover a node of the preview stands for. */ +export function hoverAtSource(source: NetItem | null): Hover | null { + return source === null ? null : { origin: "preview", provenance: { what: itemLabel(source), source } }; +} diff --git a/pocs/zeroth-playground/playground/examples-view/module-edits.test.ts b/pocs/zeroth-playground/playground/examples-view/module-edits.test.ts new file mode 100644 index 00000000..87f521d6 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/module-edits.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { NO_EDITS, editFile, isEdited, recompiled, shownText, toggleEditing } from "./module-edits"; + +describe("module edits", () => { + it("shows an edit in place of the compiled text until the text reads as compiled again", () => { + // GIVEN editing on, and net.py edited from x = 1 to x = 2 + const edited = editFile(toggleEditing(NO_EDITS), "net.py", "x = 2\n", "x = 1\n"); + // WHEN net.py is edited back to the compiled text + const restored = editFile(edited, "net.py", "x = 1\n", "x = 1\n"); + // THEN net.py shows its edit while it differs, another file shows what the compiler wrote, + // and the edit back drops the entry + expect(isEdited(edited)).toBe(true); + expect(isEdited(edited, "net.py")).toBe(true); + expect(shownText(edited, "net.py", "x = 1\n")).toBe("x = 2\n"); + expect(shownText(edited, "place_a.py", "a\n")).toBe("a\n"); + expect(restored).toEqual({ editing: true, texts: {} }); + }); + + it("drops the texts on a new compilation and keeps the toggle", () => { + // GIVEN editing on with net.py edited, and no edits at all + const edited = editFile(toggleEditing(NO_EDITS), "net.py", "x = 2\n", "x = 1\n"); + // WHEN the module is compiled again + // THEN the edited texts are gone with editing still on, and no edits stay the same object + expect(recompiled(edited)).toEqual({ editing: true, texts: {} }); + expect(recompiled(NO_EDITS)).toBe(NO_EDITS); + }); + + it("keeps the edited texts when editing is turned off", () => { + // GIVEN editing on with net.py edited + const edited = editFile(toggleEditing(NO_EDITS), "net.py", "x = 2\n", "x = 1\n"); + // WHEN editing is turned off + const off = toggleEditing(edited); + // THEN the edit is still there + expect(off).toEqual({ editing: false, texts: { "net.py": "x = 2\n" } }); + }); +}); diff --git a/pocs/zeroth-playground/playground/examples-view/module-edits.ts b/pocs/zeroth-playground/playground/examples-view/module-edits.ts new file mode 100644 index 00000000..30b8fdb3 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/module-edits.ts @@ -0,0 +1,37 @@ +/** + * Hand edits to the emitted module: whether the module editor takes edits, + * and the text of each file edited away from what the compiler wrote. The + * edits hold against one compilation only, so a new compilation drops them + * and leaves the toggle as it was. + */ +export type ModuleEdits = { + editing: boolean; + /** The edited text by file path; a file left as compiled has no entry. */ + texts: Readonly>; +}; + +export const NO_EDITS: ModuleEdits = { editing: false, texts: {} }; + +export function toggleEditing(edits: ModuleEdits): ModuleEdits { + return { ...edits, editing: !edits.editing }; +} + +/** The edits once `path` reads `text`: an edit back to the compiled text drops the file's entry. */ +export function editFile(edits: ModuleEdits, path: string, text: string, compiled: string): ModuleEdits { + const { [path]: _previous, ...others } = edits.texts; + return { ...edits, texts: text === compiled ? others : { ...others, [path]: text } }; +} + +/** What a file shows: its edit, else what the compiler wrote. */ +export function shownText(edits: ModuleEdits, path: string, compiled: string): string { + return edits.texts[path] ?? compiled; +} + +export function isEdited(edits: ModuleEdits, path?: string): boolean { + return path === undefined ? Object.keys(edits.texts).length > 0 : path in edits.texts; +} + +/** The edits after the compiler writes the module again: every text dropped, the toggle kept. */ +export function recompiled(edits: ModuleEdits): ModuleEdits { + return isEdited(edits) ? { ...edits, texts: {} } : edits; +} diff --git a/pocs/zeroth-playground/playground/examples-view/module-view.tsx b/pocs/zeroth-playground/playground/examples-view/module-view.tsx new file mode 100644 index 00000000..6a9bf3bc --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/module-view.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import { Group, Panel as ResizablePanel, Separator, usePanelRef } from "react-resizable-panels"; + +import { MonacoEditor } from "../editor/monaco-editor"; +import { DiagnosticsList } from "./diagnostics-list"; +import { FileList } from "./file-list"; +import { type Hover, hoverAtLine, litLines } from "./hover"; +import { Panel } from "../ui/panel"; +import { RESIZE_TARGET } from "../ui/resize-target"; +import { type ModuleEdits, editFile, isEdited, shownText } from "./module-edits"; +import { useFolds } from "../ui/use-folds"; + +import type { Compilation } from "../../compiler"; + +type ModuleViewProps = { + /** Names the editor's models, so undo never reaches another example's text. */ + exampleId: string; + compilation: Compilation; + /** The hand edits over the compiled files, and whether the editor takes more. */ + edits: ModuleEdits; + onEdit: (edits: ModuleEdits) => void; + hover: Hover | null; + onHover: (hover: Hover | null) => void; + onRevealIrLine: (line: number) => void; +}; + +const REFUSED = "# The lowering refused this net; the diagnostics below say why.\n"; +const NO_DOCUMENT = "# The text is not an IR document yet.\n"; + +/** + * The emitted module: the file shown in an editor, the other files in a + * small panel at its right when the layout writes several, and the + * diagnostics under both. The editor is read-only unless the edits are on; + * an edited file shows its edit and drops its trace, which no longer + * matches its lines. + */ +export const ModuleView: React.FC = ({ + exampleId, + compilation, + edits, + onEdit, + hover, + onHover, + onRevealIrLine, +}) => { + const [selectedPath, setSelectedPath] = useState("net.py"); + const filesRef = usePanelRef(); + const folds = useFolds(["files"], () => filesRef.current); + const paths = compilation.files.map((file) => file.path); + const file = + compilation.files.find((candidate) => candidate.path === selectedPath) ?? + compilation.files[0]; + const path = file?.path ?? "net.py"; + const text = + file === undefined + ? compilation.ir === null + ? NO_DOCUMENT + : REFUSED + : shownText(edits, file.path, file.text); + const trace = file === undefined || isEdited(edits, file.path) ? [] : file.trace; + const code = ( +
    + { + if (file !== undefined) { + onEdit(editFile(edits, file.path, next, file.text)); + } + }} + stickyScroll + trace={trace} + decorations={litLines("module", trace, hover)} + onHoverLine={(line) => onHover(hoverAtLine("module", trace, line))} + /> +
    + ); + + return ( +
    +
    + {paths.length > 1 ? ( + + + {code} + + + + + + + + + ) : ( + code + )} +
    + +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/index.html b/pocs/zeroth-playground/playground/index.html new file mode 100644 index 00000000..9ec77da1 --- /dev/null +++ b/pocs/zeroth-playground/playground/index.html @@ -0,0 +1,47 @@ + + + + + + + Petri net IR to Zeroth reactive modules + + +
    + + + diff --git a/pocs/zeroth-playground/playground/main.tsx b/pocs/zeroth-playground/playground/main.tsx new file mode 100644 index 00000000..b610239d --- /dev/null +++ b/pocs/zeroth-playground/playground/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./app/app"; +import "./theme/fonts"; +import "./theme/theme.css"; + +const root = document.getElementById("root"); +if (root === null) { + throw new Error("index.html has no #root element"); +} +createRoot(root).render( + + + , +); diff --git a/pocs/zeroth-playground/playground/options/option-edits.test.ts b/pocs/zeroth-playground/playground/options/option-edits.test.ts new file mode 100644 index 00000000..7e112d55 --- /dev/null +++ b/pocs/zeroth-playground/playground/options/option-edits.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { parsePetriNetIr } from "../../compiler"; +import { exampleById } from "../../examples/catalog"; +import { sameOptions, withOption } from "./option-edits"; + +import type { CompilerOptions, PetriNetIr } from "../../compiler"; + +/** The example's document with the options it opens with. */ +function opened(id: string): { ir: PetriNetIr; options: CompilerOptions } { + const example = exampleById(id); + const parsed = example === undefined ? undefined : parsePetriNetIr(example.ir); + if (example === undefined || parsed === undefined || !parsed.ok) { + throw new Error(`example ${id} does not parse`); + } + return { ir: parsed.ir, options: example.options }; +} + +describe("withOption", () => { + it("stores only the options off their default that apply to the net", () => { + // GIVEN the queue, which opens modular with a step of 0.5 + const { ir, options } = opened("queue"); + // WHEN one option at a time is changed + // THEN the panel keeps what bears on the net, and a text that is not a number changes nothing + expect(withOption(ir, options, "shape", "monolithic")).toEqual({ dt: 0.5 }); + expect(withOption(ir, options, "dt", "1")).toEqual({ shape: "modular" }); + expect(withOption(ir, { dt: 0.5 }, "dt", "1")).toEqual({}); + expect(withOption(ir, options, "dt", "abc")).toEqual({ shape: "modular", dt: 0.5 }); + expect(withOption(ir, options, "rates", "clock")).toEqual({ rates: "clock" }); + }); + + it("drops the options clock rates ignore", () => { + // GIVEN the fork under clocks, its conflicts left open + const { ir, options } = opened("fork-clocked"); + // WHEN the shape is changed, then the rates + // THEN the shape does not stick under clocks, and conflicts survive the move to coins + expect(withOption(ir, options, "shape", "modular")).toEqual({ rates: "clock", conflicts: "nondet" }); + expect(withOption(ir, options, "rates", "coin")).toEqual({ conflicts: "nondet" }); + }); + + it("ignores a value the option does not have", () => { + // GIVEN the queue + const { ir, options } = opened("queue"); + // WHEN its shape is set to a value it does not take + // THEN the options stay as they were + expect(withOption(ir, options, "shape", "round")).toEqual(options); + }); +}); + +describe("sameOptions", () => { + it("compares options as they resolve, so a default written out equals one left out", () => { + // GIVEN pairs of option sets + // WHEN they are compared + // THEN defaults count as left out, and any other value tells them apart + expect(sameOptions({}, {})).toBe(true); + expect(sameOptions({ shape: "monolithic", dt: 1 }, {})).toBe(true); + expect(sameOptions({ dt: 0.5 }, { dt: 0.5 })).toBe(true); + expect(sameOptions({ dt: 0.5 }, { dt: 0.25 })).toBe(false); + expect(sameOptions({ rates: "clock" }, {})).toBe(false); + }); +}); diff --git a/pocs/zeroth-playground/playground/options/option-edits.ts b/pocs/zeroth-playground/playground/options/option-edits.ts new file mode 100644 index 00000000..89ecd6bc --- /dev/null +++ b/pocs/zeroth-playground/playground/options/option-edits.ts @@ -0,0 +1,41 @@ +import { OPTIONS, OPTION_NAMES, optionsForNet, resolveOptions } from "../../compiler"; + +import type { CompilerOptions, OptionName, PetriNetIr } from "../../compiler"; + +/** + * The panel's edits to the options it holds. Its controls give strings, and + * it stores only the options that bear on the net: those that apply and + * are off their default. + */ + +/** The value a control's text stands for, or `undefined` when it is none of the option's values. */ +function valueOf(name: OptionName, text: string): string | number | undefined { + const values: readonly string[] | null = OPTIONS[name].values; + if (values !== null) { + return values.includes(text) ? text : undefined; + } + const number = Number(text); + return Number.isFinite(number) && number > 0 ? number : undefined; +} + +/** + * The options with one changed, as the panel stores them. A number option + * whose text does not read as a positive number keeps its value. + */ +export function withOption( + ir: PetriNetIr, + options: CompilerOptions, + name: OptionName, + text: string, +): CompilerOptions { + const value = valueOf(name, text); + const asked = value === undefined ? options : ({ ...options, [name]: value } as CompilerOptions); + return optionsForNet(asked, ir); +} + +/** Whether two option sets compile alike: every option resolves to the same value, defaults included. */ +export function sameOptions(a: CompilerOptions, b: CompilerOptions): boolean { + const left = resolveOptions(a); + const right = resolveOptions(b); + return OPTION_NAMES.every((name) => left[name] === right[name]); +} diff --git a/pocs/zeroth-playground/playground/options/option-labels.test.ts b/pocs/zeroth-playground/playground/options/option-labels.test.ts new file mode 100644 index 00000000..dd9096cd --- /dev/null +++ b/pocs/zeroth-playground/playground/options/option-labels.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { OPTIONS, OPTION_NAMES } from "../../compiler"; +import { OPTION_SECTIONS } from "./option-labels"; + +describe("OPTION_SECTIONS", () => { + const labelled = OPTION_SECTIONS.flatMap((section) => section.options); + + it("shows every compiler option once", () => { + // GIVEN the panel's sections + // WHEN their options are listed + // THEN each compiler option appears exactly once + expect(labelled.map((labels) => labels.name).toSorted()).toEqual(OPTION_NAMES.toSorted()); + }); + + it("labels exactly the compiler's values, and nests an option under one shown before it", () => { + // GIVEN each option's labels + for (const labels of labelled) { + // THEN the labelled values are the compiler's, and a parent comes first + const values = OPTIONS[labels.name].values; + expect(labels.choices?.map((choice) => choice.value).toSorted() ?? null).toEqual( + values === null ? null : [...values].toSorted(), + ); + if (labels.under !== undefined) { + expect(labelled.findIndex((other) => other.name === labels.under)).toBeLessThan(labelled.indexOf(labels)); + } + } + }); +}); diff --git a/pocs/zeroth-playground/playground/options/option-labels.ts b/pocs/zeroth-playground/playground/options/option-labels.ts new file mode 100644 index 00000000..8885ffba --- /dev/null +++ b/pocs/zeroth-playground/playground/options/option-labels.ts @@ -0,0 +1,133 @@ +import type { OptionName } from "../../compiler"; + +/** + * How the compiler options read in the panel: grouped by what they decide, + * each with a label, a one-line hint and labels for its values. An option + * that only matters through another one names it in `under` and sits nested + * beneath it. The values themselves are the compiler's. + */ + +export type OptionChoice = { value: string; label: string }; + +export type OptionLabels = { + name: OptionName; + label: string; + hint: string; + /** The option this one depends on, drawn above it. */ + under?: OptionName; + /** The values in the order the menu lists them; absent for a number. */ + choices?: readonly OptionChoice[]; +}; + +/** A value as the panel shows it: its choice's label, or the number as written. */ +export function valueLabel(labels: OptionLabels, value: string): string { + return labels.choices?.find((choice) => choice.value === value)?.label ?? value; +} + +/** + * What an option that does not apply reads in place of a value. The default + * it would show has no effect on the net: under clock rates, Shape would + * read monolithic over composed modules. + */ +export const NOT_USED = "not used"; + +export type OptionSection = { + title: string; + /** What the section's options decide. */ + about: string; + options: readonly OptionLabels[]; +}; + +export const OPTION_SECTIONS: readonly OptionSection[] = [ + { + title: "Structure", + about: "How the net becomes modules and files", + options: [ + { + name: "shape", + label: "Shape", + hint: "Modular: one module per transition and per place, composed. Monolithic: one module whose next method is a whole step. Coloured nets compile monolithic only.", + choices: [ + { value: "modular", label: "modular" }, + { value: "monolithic", label: "monolithic" }, + ], + }, + { + name: "layout", + label: "Files", + under: "shape", + hint: "A composed system in one Python file, or net.py plus one file per module.", + choices: [ + { value: "single", label: "one file" }, + { value: "per-module", label: "one per module" }, + ], + }, + ], + }, + { + title: "Rates", + about: "How a stochastic transition's rate becomes a firing", + options: [ + { + name: "rates", + label: "Rates", + hint: "Coin: a draw tested each step of dt, in LIA or LRA. Clock: an exponential clock in continuous time, in SPN.", + choices: [ + { value: "coin", label: "coin" }, + { value: "clock", label: "clock" }, + ], + }, + { + name: "dt", + label: "Time step", + under: "rates", + hint: "The step a coin is tested over and dynamics advance by.", + }, + { + name: "marking", + label: "Marking", + under: "rates", + hint: "Real places, or Int places with one LRA draw module per transition.", + choices: [ + { value: "real", label: "Real" }, + { value: "int", label: "Int" }, + ], + }, + ], + }, + { + title: "Choices", + about: "Who decides what a step's sweep would decide", + options: [ + { + name: "conflicts", + label: "Conflicts", + hint: "Sweep: record order settles a shared input place. Nondet: each transition in a conflict waits for a pick input.", + choices: [ + { value: "sweep", label: "sweep" }, + { value: "nondet", label: "nondet" }, + ], + }, + { + name: "control", + label: "Control", + hint: "Closed: a controllable transition fires when enabled. Open: it waits for a go input.", + choices: [ + { value: "closed", label: "closed" }, + { value: "open", label: "open" }, + ], + }, + ], + }, + { + title: "Coloured tokens", + about: "How a coloured place is laid out", + options: [ + { + name: "slots", + label: "Slots", + hint: "Positions for a coloured place without a capacity.", + }, + ], + }, +]; diff --git a/pocs/zeroth-playground/playground/pointer/pointer-motion.test.ts b/pocs/zeroth-playground/playground/pointer/pointer-motion.test.ts new file mode 100644 index 00000000..1b01c3fe --- /dev/null +++ b/pocs/zeroth-playground/playground/pointer/pointer-motion.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { NO_MOTION, restLeft, sampleMotion } from "./pointer-motion"; + +import type { PointerSample } from "./pointer-motion"; + +function motionOf(samples: PointerSample[]) { + return samples.reduce(sampleMotion, NO_MOTION); +} + +describe("sampleMotion", () => { + it("counts a sweep as moving and a drift as resting", () => { + // GIVEN a sweep of 10 px in 16 ms, and a drift of 1 px in 40 ms + const sweep = [ + { x: 0, y: 0, t: 0 }, + { x: 10, y: 0, t: 16 }, + ]; + const drift = [ + { x: 0, y: 0, t: 0 }, + { x: 1, y: 0, t: 40 }, + ]; + // WHEN each is read as motion + // THEN the sweep moved at its last sample, and the drift never moved + expect(motionOf(sweep).movedAt).toBe(16); + expect(motionOf(drift).movedAt).toBe(Number.NEGATIVE_INFINITY); + }); + + it("keeps the time of the last sweep through the drift that follows it", () => { + // GIVEN a sweep at 10 ms, then a drift of 1 px in 40 ms + const samples = [ + { x: 0, y: 0, t: 0 }, + { x: 30, y: 40, t: 10 }, + { x: 31, y: 40, t: 50 }, + ]; + // WHEN they are read as motion + const motion = motionOf(samples); + // THEN the pointer last moved at the sweep + expect(motion.movedAt).toBe(10); + }); + + it("reads two samples at one timestamp as a millisecond apart", () => { + // GIVEN a move of 2 px, and no move, each between two samples at 5 ms + const move = [ + { x: 0, y: 0, t: 5 }, + { x: 2, y: 0, t: 5 }, + ]; + const still = [ + { x: 0, y: 0, t: 5 }, + { x: 0, y: 0, t: 5 }, + ]; + // WHEN each is read as motion + // THEN the move counts as moving at 5 ms, and standing still does not + expect(motionOf(move).movedAt).toBe(5); + expect(motionOf(still).movedAt).toBe(Number.NEGATIVE_INFINITY); + }); +}); + +describe("restLeft", () => { + it("waits out the rest of the pause after the last sweep", () => { + // GIVEN a sweep that ends at 100 ms, and a pointer that never moved + const motion = motionOf([ + { x: 0, y: 0, t: 0 }, + { x: 10, y: 0, t: 100 }, + ]); + // WHEN a 30 ms rest is asked for at 110 ms and at 140 ms + // THEN 20 ms are left at 110 ms, none at 140 ms, and none for a pointer that never moved + expect(restLeft(motion, 110, 30)).toBe(20); + expect(restLeft(motion, 140, 30)).toBe(0); + expect(restLeft(NO_MOTION, 0, 30)).toBe(0); + }); +}); diff --git a/pocs/zeroth-playground/playground/pointer/pointer-motion.ts b/pocs/zeroth-playground/playground/pointer/pointer-motion.ts new file mode 100644 index 00000000..70134aae --- /dev/null +++ b/pocs/zeroth-playground/playground/pointer/pointer-motion.ts @@ -0,0 +1,38 @@ +/** + * Whether the pointer is sweeping or resting, read off its moves. A hand + * resting on a mouse or a trackpad still drifts a little, so the pointer + * counts as resting below a speed close to zero rather than at zero. + */ + +/** The speed, in pixels per millisecond, below which the pointer rests: 50 px a second. */ +const RESTING_SPEED = 0.05; + +/** + * How long the pointer rests on a target before a hover takes effect. The + * Compiler view's texts quote it, so it lives here, away from the page + * listener in `use-dwell.ts`. + */ +export const DWELL_MS = 60; + +/** Where the pointer was at a time, in the page's milliseconds. */ +export type PointerSample = { x: number; y: number; t: number }; + +/** The pointer's last position, and when it last moved faster than resting. */ +export type PointerMotion = { last: PointerSample | null; movedAt: number }; + +/** Before any move, the pointer has always rested. */ +export const NO_MOTION: PointerMotion = { last: null, movedAt: Number.NEGATIVE_INFINITY }; + +/** The motion after one more move. Two moves at one timestamp count as a millisecond apart. */ +export function sampleMotion(motion: PointerMotion, sample: PointerSample): PointerMotion { + const { last } = motion; + const moving = + last !== null && + Math.hypot(sample.x - last.x, sample.y - last.y) > RESTING_SPEED * Math.max(sample.t - last.t, 1); + return { last: sample, movedAt: moving ? sample.t : motion.movedAt }; +} + +/** How long the pointer must still rest, at `now`, to have rested for `ms`. */ +export function restLeft(motion: PointerMotion, now: number, ms: number): number { + return Math.max(0, motion.movedAt + ms - now); +} diff --git a/pocs/zeroth-playground/playground/pointer/use-dwell.ts b/pocs/zeroth-playground/playground/pointer/use-dwell.ts new file mode 100644 index 00000000..35c52720 --- /dev/null +++ b/pocs/zeroth-playground/playground/pointer/use-dwell.ts @@ -0,0 +1,42 @@ +import { useRef, useState } from "react"; + +import { DWELL_MS, NO_MOTION, restLeft, sampleMotion } from "./pointer-motion"; + +// One listener for the page follows the pointer's speed for every dwell. +let motion = NO_MOTION; + +function trackMotion(event: PointerEvent) { + motion = sampleMotion(motion, { x: event.clientX, y: event.clientY, t: event.timeStamp }); +} + +const LISTENING = { capture: true, passive: true } as const; +window.addEventListener("pointermove", trackMotion, LISTENING); +import.meta.hot?.dispose(() => window.removeEventListener("pointermove", trackMotion, LISTENING)); + +/** + * A hovered value that only takes effect once the pointer settles on it: + * `pointAt` reports what the pointer is on, and the returned value follows + * once `DWELL_MS` have passed since then and since the pointer last moved + * faster than resting. A pointer sweeping across lines, nodes or stages + * never settles on any of them, and the last value it settled on holds + * until it does. The timer lives in the handler that starts it. + */ +export function useDwell(initial: T): [settled: T, pointAt: (value: T) => void] { + const [settled, setSettled] = useState(initial); + const timer = useRef(undefined); + + function pointAt(value: T) { + window.clearTimeout(timer.current); + function settle() { + const left = restLeft(motion, performance.now(), DWELL_MS); + if (left > 0) { + timer.current = window.setTimeout(settle, left); + } else { + setSettled(value); + } + } + timer.current = window.setTimeout(settle, DWELL_MS); + } + + return [settled, pointAt]; +} diff --git a/pocs/zeroth-playground/playground/preview/layout-net.ts b/pocs/zeroth-playground/playground/preview/layout-net.ts new file mode 100644 index 00000000..fc505878 --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/layout-net.ts @@ -0,0 +1,153 @@ +import ELK from "elkjs/lib/elk.bundled.js"; + +import type { ElkExtendedEdge, ElkNode } from "elkjs/lib/elk-api"; +import type { NetEdge, NetGraph, NetNode } from "./net-graph"; + +/** + * Positions for the net through elkjs's layered algorithm, left to right, + * on the main thread: the nets are small and the bundled ELK needs no + * worker file beside the single HTML. Only the geometry is cached, by the + * graph's structure, and the drawing reads everything else from the graph, + * so an IR edit that keeps every node and arc redraws at once. + */ + +export type Point = { x: number; y: number }; + +export type LaidOutNode = NetNode & { + x: number; + y: number; + width: number; + height: number; +}; + +export type LaidOutEdge = NetEdge & { + /** From the source's boundary to the target's, bends included. */ + points: Point[]; +}; + +export type NetLayout = { + width: number; + height: number; + nodes: LaidOutNode[]; + edges: LaidOutEdge[]; +}; + +type Box = { x: number; y: number; width: number; height: number }; + +/** Where elkjs put each node and how it routed each arc, by id: nothing else from the graph. */ +export type NetGeometry = { + width: number; + height: number; + positions: Map; + routes: Map; +}; + +const PLACE_SIZE = 44; +const TRANSITION_SIZE = 36; + +function nodeSize(node: NetNode): number { + return node.kind === "place" ? PLACE_SIZE : TRANSITION_SIZE; +} + +const LAYOUT_OPTIONS = { + "elk.algorithm": "layered", + "elk.direction": "RIGHT", + "elk.edgeRouting": "POLYLINE", + "elk.spacing.nodeNode": "44", + "elk.layered.spacing.nodeNodeBetweenLayers": "64", + "elk.layered.spacing.edgeNodeBetweenLayers": "24", + "elk.spacing.edgeNode": "24", + "elk.padding": "[top=16,left=16,bottom=28,right=16]", + "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF", + "elk.layered.crossingMinimization.semiInteractive": "true", +}; + +let elk: InstanceType | null = null; + +export async function layoutNet(graph: NetGraph): Promise { + elk ??= new ELK(); + const root: ElkNode = { + id: "net", + layoutOptions: LAYOUT_OPTIONS, + children: graph.nodes.map( + (node): ElkNode => ({ + id: node.id, + width: nodeSize(node), + height: nodeSize(node), + }), + ), + edges: graph.edges.map( + (edge): ElkExtendedEdge => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + }), + ), + }; + const laidOut = await elk.layout(root); + return { + width: laidOut.width ?? 0, + height: laidOut.height ?? 0, + positions: new Map( + (laidOut.children ?? []).map((child) => [ + child.id, + { x: child.x ?? 0, y: child.y ?? 0, width: child.width ?? 0, height: child.height ?? 0 }, + ]), + ), + routes: new Map( + (laidOut.edges ?? []).flatMap((edge) => { + const section = edge.sections?.[0]; + return section === undefined + ? [] + : [[edge.id, [section.startPoint, ...(section.bendPoints ?? []), section.endPoint]]]; + }), + ), + }; +} + +/** The graph drawn at a geometry: the nodes and arcs come from the graph, their places from the geometry. */ +export function placeGraph(graph: NetGraph, geometry: NetGeometry): NetLayout { + return { + width: geometry.width, + height: geometry.height, + nodes: graph.nodes.map((node) => ({ + ...node, + ...(geometry.positions.get(node.id) ?? { + x: 0, + y: 0, + width: nodeSize(node), + height: nodeSize(node), + }), + })), + edges: graph.edges.map((edge) => ({ ...edge, points: geometry.routes.get(edge.id) ?? [] })), + }; +} + +/** What a layout depends on: the nodes' ids and sizes and the edges' ends, in order. */ +export function layoutKey(graph: NetGraph): string { + return JSON.stringify([ + graph.nodes.map((node) => [node.id, nodeSize(node)]), + graph.edges.map((edge) => [edge.id, edge.source, edge.target]), + ]); +} + +const CACHE_LIMIT = 64; +const cache = new Map>(); + +/** The geometry for a graph's structure, one promise per structure so `use` can await it across renders. */ +export function layoutFor(graph: NetGraph): Promise { + const key = layoutKey(graph); + const cached = cache.get(key); + if (cached !== undefined) { + return cached; + } + const promise = layoutNet(graph); + cache.set(key, promise); + if (cache.size > CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) { + cache.delete(oldest); + } + } + return promise; +} diff --git a/pocs/zeroth-playground/playground/preview/net-graph.test.ts b/pocs/zeroth-playground/playground/preview/net-graph.test.ts new file mode 100644 index 00000000..be70570e --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-graph.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; + +import { parsePetriNetIr } from "../../compiler"; +import { exampleById } from "../../examples/catalog"; +import { layoutFor, layoutKey, layoutNet, placeGraph } from "./layout-net"; +import { netGraph } from "./net-graph"; + +import type { PetriNetIr } from "../../compiler"; + +function irOf(id: string): PetriNetIr { + const parsed = parsePetriNetIr(exampleById(id)?.ir ?? ""); + if (!parsed.ok) { + throw new Error(`example ${id} does not parse`); + } + return parsed.ir; +} + +describe("netGraph", () => { + it("reads places with their marking and transitions with their rates and arcs", () => { + // GIVEN the queue + // WHEN its graph is read + const graph = netGraph(irOf("queue")); + // THEN the places come first with their counts and capacity, then the rated transitions, then the arcs + expect(graph.nodes).toEqual([ + { id: "place:Waiting", kind: "place", name: "Waiting", tokens: 0, capacity: 4 }, + { id: "place:Served", kind: "place", name: "Served", tokens: 0 }, + { id: "transition:Arrive", kind: "transition", name: "Arrive", rate: 2.5, guarded: false, controllable: false }, + { id: "transition:Serve", kind: "transition", name: "Serve", rate: 3, guarded: false, controllable: false }, + ]); + expect(graph.edges.map((edge) => [edge.source, edge.target, edge.weight, edge.kind])).toEqual([ + ["transition:Arrive", "place:Waiting", 1, "standard"], + ["place:Waiting", "transition:Serve", 1, "standard"], + ["transition:Serve", "place:Served", 1, "standard"], + ]); + }); + + it("keeps arc kinds and weights, coloured places and code rates", () => { + // GIVEN the boiler, the conflict and the drones + // WHEN their graphs are read + const boiler = netGraph(irOf("boiler")); + const conflict = netGraph(irOf("conflict")); + const drones = netGraph(irOf("drones")); + // THEN the boiler keeps its read arc, coloured tank and guard, the conflict its arc weight and + // controllable transition, and the drones a rate written as code + expect(boiler.edges[0]).toMatchObject({ place: "Tank", transition: "Alarm", kind: "read" }); + expect(boiler.nodes[0]).toMatchObject({ kind: "place", name: "Tank", tokens: 1, colour: "Vessel", capacity: 1 }); + expect(boiler.nodes[2]).toMatchObject({ kind: "transition", name: "Alarm", guarded: true }); + expect(conflict.edges[0]).toMatchObject({ place: "Pool", transition: "TakeLeft", weight: 2 }); + expect(conflict.nodes[3]).toMatchObject({ name: "TakeLeft", controllable: true }); + expect(drones.nodes[3]).toMatchObject({ name: "Launch", rate: "code" }); + }); + + it("drops an arc to a place the IR does not declare", () => { + // GIVEN a transition with an output arc to an undeclared place + const ir: PetriNetIr = { + name: "dangling", + kind: "plain", + places: { A: null }, + transitions: { Go: { inputs: { A: null }, outputs: { Nowhere: null } } }, + }; + // WHEN its graph is read + const graph = netGraph(ir); + // THEN only the arc from the declared place is drawn + expect(graph.edges).toHaveLength(1); + }); +}); + +describe("layoutNet", () => { + it("places every node and routes every arc, left to right", async () => { + // GIVEN the cycle's graph + const graph = netGraph(irOf("cycle")); + // WHEN it is laid out and placed + const layout = placeGraph(graph, await layoutNet(graph)); + // THEN every node sits in the drawing, every arc has a route, and A comes before Go + expect(layout.nodes).toHaveLength(4); + expect(layout.edges).toHaveLength(4); + expect(layout.width).toBeGreaterThan(0); + for (const node of layout.nodes) { + expect(node.x).toBeGreaterThanOrEqual(0); + expect(node.y).toBeGreaterThanOrEqual(0); + } + for (const edge of layout.edges) { + expect(edge.points.length).toBeGreaterThanOrEqual(2); + } + const a = layout.nodes.find((node) => node.name === "A"); + const go = layout.nodes.find((node) => node.name === "Go"); + expect((a?.x ?? 0) < (go?.x ?? 0)).toBe(true); + }); + + it("keys the cache on structure alone and reuses one promise per structure", () => { + // GIVEN the cycle, the cycle with another marking, and the fork + const cycle = irOf("cycle"); + const graph = netGraph(cycle); + const remarked = netGraph({ ...cycle, marking: { A: 3 } }); + const fork = netGraph(irOf("fork")); + // WHEN their layouts are looked up + // THEN the two cycles share a key and a promise, and the fork has a key of its own + expect(layoutKey(graph)).toBe(layoutKey(remarked)); + expect(layoutFor(graph)).toBe(layoutFor(remarked)); + expect(layoutKey(fork)).not.toBe(layoutKey(graph)); + }); + + it("places a graph at a cached geometry with the graph's own marking", async () => { + // GIVEN the cycle laid out first + const cycle = irOf("cycle"); + const geometry = layoutFor(netGraph(cycle)); + await geometry; + // WHEN the cycle with A: 3 is placed through the same cache + const remarked = netGraph({ ...cycle, marking: { A: 3 } }); + const layout = placeGraph(remarked, await layoutFor(remarked)); + // THEN its A has 3 tokens, and the promise is still the shared one + expect(layout.nodes.find((node) => node.name === "A")).toMatchObject({ tokens: 3 }); + expect(layoutFor(remarked)).toBe(geometry); + }); +}); diff --git a/pocs/zeroth-playground/playground/preview/net-graph.ts b/pocs/zeroth-playground/playground/preview/net-graph.ts new file mode 100644 index 00000000..79e407e6 --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-graph.ts @@ -0,0 +1,124 @@ +import { arcWeight, initialTokens } from "../../compiler"; + +import type { NetItem, PetriNetIr } from "../../compiler"; + +/** + * The net as a drawing reads it: one node per place and transition, one + * edge per arc, and nothing about layout. Pure over the IR, so the preview + * is a function of the document. + */ + +export type PlaceNode = { + id: string; + kind: "place"; + name: string; + tokens: number; + capacity?: number; + colour?: string; +}; + +export type TransitionNode = { + id: string; + kind: "transition"; + name: string; + /** A constant rate, `"code"` for a rate over the tokens, absent for a plain transition. */ + rate?: number | "code"; + guarded: boolean; + controllable: boolean; +}; + +export type NetNode = PlaceNode | TransitionNode; + +export type NetEdge = { + id: string; + /** Node ids; an input arc runs place to transition, an output arc the other way. */ + source: string; + target: string; + place: string; + transition: string; + weight: number; + kind: "standard" | "read" | "inhibitor"; +}; + +export type NetGraph = { + nodes: NetNode[]; + edges: NetEdge[]; +}; + +function placeId(name: string): string { + return `place:${name}`; +} +function transitionId(name: string): string { + return `transition:${name}`; +} + +/** The net item a node stands for, as the traces name it. */ +export function nodeSource(node: NetNode): NetItem { + return { kind: node.kind, name: node.name }; +} + +function definedOnly(object: T): T { + return Object.fromEntries( + Object.entries(object).filter(([, value]) => value !== undefined), + ) as T; +} + +export function netGraph(ir: PetriNetIr): NetGraph { + const places = Object.entries(ir.places).map( + ([name, place]): PlaceNode => + definedOnly({ + id: placeId(name), + kind: "place", + name, + tokens: initialTokens(ir, name), + capacity: place?.capacity, + colour: place?.colour, + }), + ); + const transitions = Object.entries(ir.transitions).map( + ([name, transition]): TransitionNode => + definedOnly({ + id: transitionId(name), + kind: "transition", + name, + rate: + transition.rate === undefined + ? undefined + : typeof transition.rate === "number" + ? transition.rate + : "code", + guarded: transition.guard !== undefined, + controllable: transition.controllable === true, + }), + ); + const edges = Object.entries(ir.transitions).flatMap(([name, transition]) => [ + ...Object.entries(transition.inputs ?? {}).map( + ([place, arc]): NetEdge => ({ + id: `${place}->${name}`, + source: placeId(place), + target: transitionId(name), + place, + transition: name, + weight: arcWeight(arc), + kind: arc?.kind ?? "standard", + }), + ), + ...Object.entries(transition.outputs ?? {}).map( + ([place, arc]): NetEdge => ({ + id: `${name}->${place}`, + source: transitionId(name), + target: placeId(place), + place, + transition: name, + weight: arcWeight(arc), + kind: "standard", + }), + ), + ]); + const known = new Set([...places, ...transitions].map((node) => node.id)); + return { + nodes: [...places, ...transitions], + // An arc to a place the IR does not declare is the lowering's error to report, not a node. + edges: edges.filter((edge) => known.has(edge.source) && known.has(edge.target)), + }; +} diff --git a/pocs/zeroth-playground/playground/preview/net-preview.tsx b/pocs/zeroth-playground/playground/preview/net-preview.tsx new file mode 100644 index 00000000..1c3c908e --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-preview.tsx @@ -0,0 +1,33 @@ +import { Suspense, useDeferredValue } from "react"; + +import { netGraph } from "./net-graph"; +import { Drawing } from "./net-preview/drawing"; + +import type { NetItem, PetriNetIr } from "../../compiler"; + +type NetPreviewProps = { + ir: PetriNetIr; + /** The net item lit by a hover in any view. */ + lit: NetItem | null; + onHover: (source: NetItem | null) => void; +}; + +/** + * The net as a plain SVG: places as circles with their marking as dots, + * transitions as squares, arcs as arrows with their weights, laid out by + * elkjs. Hovering a place or transition lights it and reports it, so the + * editors light its lines; a lit item from either editor shows here. The one + * component that takes a `PetriNetIr`, so a canvas can replace it later. + */ +export const NetPreview: React.FC = ({ ir, lit, onHover }) => { + const graph = netGraph(ir); + // A structural change lays out in the background while the last drawing stays up. + const shown = useDeferredValue(graph); + return ( +
    + Laying out the net…

    }> + +
    +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/preview/net-preview/arc-view.tsx b/pocs/zeroth-playground/playground/preview/net-preview/arc-view.tsx new file mode 100644 index 00000000..030876c9 --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-preview/arc-view.tsx @@ -0,0 +1,46 @@ +import type { NetItem } from "../../../compiler"; +import type { LaidOutEdge, Point } from "../layout-net"; + +type ArcViewProps = { + edge: LaidOutEdge; + /** The net item lit by a hover in any view. */ + lit: NetItem | null; +}; + +/** An arc lights with either of its ends. */ +function edgeLit(edge: LaidOutEdge, lit: NetItem | null): boolean { + return ( + lit !== null && + ((lit.kind === "place" && lit.name === edge.place) || + (lit.kind === "transition" && lit.name === edge.transition)) + ); +} + +function midpoint(points: Point[]): Point { + const middle = Math.floor((points.length - 1) / 2); + const from = points[middle] ?? { x: 0, y: 0 }; + const to = points[middle + 1] ?? from; + return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; +} + +/** An arc as an arrow, or a line ending in a circle for an inhibitor arc, with a weight other than one over its middle. */ +export const ArcView: React.FC = ({ edge, lit }) => { + if (edge.points.length < 2) { + return null; + } + const label = midpoint(edge.points); + return ( + + `${point.x},${point.y}`).join(" ")} + markerEnd={edge.kind === "inhibitor" ? "url(#net-inhibit)" : "url(#net-arrow)"} + /> + {edge.weight === 1 ? null : ( + + {edge.weight} + + )} + + ); +}; diff --git a/pocs/zeroth-playground/playground/preview/net-preview/drawing.tsx b/pocs/zeroth-playground/playground/preview/net-preview/drawing.tsx new file mode 100644 index 00000000..d2ef180a --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-preview/drawing.tsx @@ -0,0 +1,67 @@ +import { use } from "react"; + +import { layoutFor, placeGraph } from "../layout-net"; +import { ArcView } from "./arc-view"; +import { NodeView } from "./node-view"; + +import type { NetItem } from "../../../compiler"; +import type { NetGraph } from "../net-graph"; + +type DrawingProps = { + graph: NetGraph; + /** The net item lit by a hover in any view. */ + lit: NetItem | null; + onHover: (source: NetItem | null) => void; +}; + +/** How far past its laid-out size the drawing may grow to fill the panel. */ +const SCALE_CAP = 1.6; + +/** The laid-out net as one SVG, scaled to fit. It suspends while elkjs lays the graph out. */ +export const Drawing: React.FC = ({ graph, lit, onHover }) => { + const layout = placeGraph(graph, use(layoutFor(graph))); + if (layout.nodes.length === 0) { + return

    The net has no places or transitions yet.

    ; + } + return ( + + + + + + + + + + {layout.edges.map((edge) => ( + + ))} + {layout.nodes.map((node) => ( + + ))} + + ); +}; diff --git a/pocs/zeroth-playground/playground/preview/net-preview/marking.tsx b/pocs/zeroth-playground/playground/preview/net-preview/marking.tsx new file mode 100644 index 00000000..b15c6fb5 --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-preview/marking.tsx @@ -0,0 +1,35 @@ +import type { LaidOutNode, Point } from "../layout-net"; + +/** The most tokens drawn as dots; a larger count is written as a number. */ +const MAX_DOTS = 4; + +/** Token dots for a small count, the number for a larger one. */ +export const Marking: React.FC<{ node: LaidOutNode }> = ({ node }) => { + if (node.kind !== "place" || node.tokens === 0) { + return null; + } + const cx = node.x + node.width / 2; + const cy = node.y + node.height / 2; + if (node.tokens > MAX_DOTS) { + return ( + + {node.tokens} + + ); + } + const offsets: Point[] = + node.tokens === 1 + ? [{ x: 0, y: 0 }] + : node.tokens === 2 + ? [{ x: -6, y: 0 }, { x: 6, y: 0 }] + : node.tokens === 3 + ? [{ x: -6, y: 4 }, { x: 6, y: 4 }, { x: 0, y: -6 }] + : [{ x: -6, y: -6 }, { x: 6, y: -6 }, { x: -6, y: 6 }, { x: 6, y: 6 }]; + return ( + + {offsets.map((offset, index) => ( + + ))} + + ); +}; diff --git a/pocs/zeroth-playground/playground/preview/net-preview/node-view.tsx b/pocs/zeroth-playground/playground/preview/net-preview/node-view.tsx new file mode 100644 index 00000000..8ebd7200 --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-preview/node-view.tsx @@ -0,0 +1,69 @@ +import { sameItem } from "../../../compiler"; +import { nodeSource } from "../net-graph"; +import { Marking } from "./marking"; + +import type { NetItem } from "../../../compiler"; +import type { LaidOutNode } from "../layout-net"; + +type NodeViewProps = { + node: LaidOutNode; + /** The net item lit by a hover in any view. */ + lit: NetItem | null; + onHover: (source: NetItem | null) => void; +}; + +/** The line under a node's name: a place's colour and capacity, a transition's rate and guard. */ +function nodeDetail(node: LaidOutNode): string | null { + const parts = + node.kind === "place" + ? [ + ...(node.colour === undefined ? [] : [node.colour]), + ...(node.capacity === undefined ? [] : [`≤ ${node.capacity}`]), + ] + : [ + ...(node.rate === undefined ? [] : [`λ ${node.rate === "code" ? "code" : node.rate}`]), + ...(node.guarded ? ["guard"] : []), + ]; + return parts.length === 0 ? null : parts.join(" · "); +} + +/** + * A place as a circle with its marking, or a transition as a square, dashed + * when it is controllable, with its name and detail under it. Hovering it + * reports the item it stands for. + */ +export const NodeView: React.FC = ({ node, lit, onHover }) => { + const cx = node.x + node.width / 2; + const detail = nodeDetail(node); + return ( + onHover(nodeSource(node))} + onMouseLeave={() => onHover(null)} + > + {node.kind === "place" ? ( + + ) : ( + + )} + + + {node.name} + + {detail === null ? null : ( + + {detail} + + )} + + ); +}; diff --git a/pocs/zeroth-playground/playground/scripts/screenshot.ts b/pocs/zeroth-playground/playground/scripts/screenshot.ts new file mode 100644 index 00000000..6b5c8818 --- /dev/null +++ b/pocs/zeroth-playground/playground/scripts/screenshot.ts @@ -0,0 +1,54 @@ +/** + * Screenshots every example the picker lists at 1440x900 into `screenshots/`, + * then the compiler view, opening `dist/index.html` from disk as a reader + * would. Run `pnpm build` first, then `pnpm screenshot`. + */ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { chromium } from "playwright"; + +const root = path.resolve(import.meta.dirname, "../.."); +const page = pathToFileURL(path.join(root, "dist", "index.html")).href; +const out = path.join(root, "screenshots"); + +const browser = await chromium.launch(); +try { + const tab = await browser.newPage({ + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + }); + await tab.goto(page); + // The picker lists the examples up the ladder; each one opens from its hash on a fresh load. + await tab.click(".picker__trigger"); + const ids = await tab.$$eval('.picker__list [role="option"]', (options) => + options.map((option) => option.getAttribute("data-example") ?? ""), + ); + await mkdir(out, { recursive: true }); + for (const id of ids) { + await tab.goto(`${page}#${id}`); + await tab.reload(); + await tab.waitForSelector(".monaco-editor .view-line"); + // Both views stay mounted, the hidden one under Activity: wait on what is visible. + await tab.locator(".net-frame svg, .net-frame .note").filter({ visible: true }).first().waitFor(); + await tab.locator(".docs h2").filter({ visible: true }).first().waitFor(); + await tab.evaluate(() => document.fonts.ready); + await tab.waitForTimeout(400); + const file = path.join(out, `${id}.png`); + await tab.screenshot({ path: file }); + console.log(`wrote ${path.relative(root, file)}`); + } + // The compiler view, fed by the first example. + await tab.goto(`${page}#${ids[0] ?? "cycle"}`); + await tab.reload(); + await tab.waitForSelector(".monaco-editor .view-line"); + await tab.click('.header__view:text-is("Compiler")'); + await tab.locator(".panel__body svg").filter({ visible: true }).first().waitFor(); + await tab.waitForTimeout(400); + const compiler = path.join(out, "compiler.png"); + await tab.screenshot({ path: compiler }); + console.log(`wrote ${path.relative(root, compiler)}`); +} finally { + await browser.close(); +} diff --git a/pocs/zeroth-playground/playground/theme/fonts.ts b/pocs/zeroth-playground/playground/theme/fonts.ts new file mode 100644 index 00000000..78d9b384 --- /dev/null +++ b/pocs/zeroth-playground/playground/theme/fonts.ts @@ -0,0 +1,7 @@ +/** + * The two faces of the theme, weight axis only: Inter for prose and controls, + * JetBrains Mono for code, labels and the title. Each `wght.css` declares one + * `@font-face` per script subset, so a build inlines the subsets it ships. + */ +import "@fontsource-variable/inter/wght.css"; +import "@fontsource-variable/jetbrains-mono/wght.css"; diff --git a/pocs/zeroth-playground/playground/theme/hash-wordmark.tsx b/pocs/zeroth-playground/playground/theme/hash-wordmark.tsx new file mode 100644 index 00000000..4a044712 --- /dev/null +++ b/pocs/zeroth-playground/playground/theme/hash-wordmark.tsx @@ -0,0 +1,34 @@ +const MARK_PATHS = [ + "M25.812 45.9545H38.6308V-0.000294685H25.812V45.9545Z", + "M7.40768 45.9545H20.2254V-0.000294685H7.40768V45.9545Z", + "M0 19.6963H46.0385V6.90085H0V19.6963Z", + "M0 38.396H46.0385V25.6015H0V38.396Z", +] as const; + +const LETTER_PATHS = [ + "M64.069 7.04006H72.817V18.8328H84.045V7.04006H92.793V38.5475H84.045V26.5747H72.817V38.5475H64.069V7.04006Z", + "M120.485 26.1246L116.968 17.1674L113.406 26.1246H120.485ZM112.82 6.815H121.252L134.69 38.5475H125.31L123.011 32.9212H110.836L108.581 38.5475H99.3821L112.82 6.815Z", + "M153.679 39.0876C150.853 39.0876 148.17 38.6525 145.63 37.7823C143.09 36.9121 140.843 35.6068 138.889 33.8664L143.759 28.06C146.915 30.5806 150.327 31.8409 153.995 31.8409C155.167 31.8409 156.062 31.6534 156.678 31.2783C157.294 30.9032 157.602 30.3706 157.602 29.6804V29.5904C157.602 29.2603 157.527 28.9677 157.377 28.7127C157.226 28.4576 156.948 28.21 156.543 27.97C156.137 27.7299 155.596 27.4974 154.919 27.2723C154.243 27.0473 153.363 26.8147 152.281 26.5747C150.598 26.1846 149.035 25.757 147.592 25.2919C146.149 24.8267 144.894 24.2266 143.826 23.4914C142.759 22.7563 141.918 21.8411 141.301 20.7458C140.685 19.6505 140.377 18.2927 140.377 16.6723V16.5823C140.377 15.112 140.655 13.7616 141.211 12.5314C141.767 11.3011 142.579 10.2358 143.646 9.3356C144.713 8.43538 145.998 7.73772 147.501 7.2426C149.005 6.74749 150.703 6.49993 152.597 6.49993C155.302 6.49993 157.715 6.85251 159.834 7.55768C161.954 8.26285 163.87 9.30558 165.584 10.6859L161.21 16.8524C159.767 15.8321 158.286 15.0595 156.768 14.5343C155.25 14.0092 153.784 13.7466 152.371 13.7466C151.319 13.7466 150.53 13.9417 150.004 14.3318C149.478 14.7219 149.215 15.202 149.215 15.7721V15.8621C149.215 16.2222 149.298 16.5373 149.463 16.8074C149.628 17.0774 149.914 17.325 150.32 17.55C150.726 17.7751 151.282 18.0001 151.988 18.2252C152.695 18.4502 153.589 18.6828 154.671 18.9229C156.475 19.313 158.106 19.7706 159.564 20.2957C161.022 20.8208 162.262 21.4585 163.284 22.2086C164.306 22.9588 165.088 23.8665 165.629 24.9318C166.17 25.997 166.44 27.2648 166.44 28.7352V28.8252C166.44 30.4456 166.132 31.8859 165.516 33.1462C164.9 34.4065 164.035 35.4793 162.923 36.3645C161.811 37.2497 160.473 37.9248 158.91 38.3899C157.347 38.8551 155.603 39.0876 153.679 39.0876Z", + "M174.202 7.04006H182.95V18.8328H194.178V7.04006H202.926V38.5475H194.178V26.5747H182.95V38.5475H174.202V7.04006Z", +] as const; + +type HashWordmarkProps = Omit, "viewBox" | "fill">; + +/** + * The HASH wordmark as a single-colour inline SVG: the glyph paths of + * `hash.svg` with `currentColor` in place of the gradient fills, so the page + * greys it through CSS. + */ +export const HashWordmark: React.FC = (props) => { + return ( + + HASH + {MARK_PATHS.map((d) => ( + + ))} + {LETTER_PATHS.map((d) => ( + + ))} + + ); +}; diff --git a/pocs/zeroth-playground/playground/theme/theme.css b/pocs/zeroth-playground/playground/theme/theme.css new file mode 100644 index 00000000..59e87734 --- /dev/null +++ b/pocs/zeroth-playground/playground/theme/theme.css @@ -0,0 +1,1489 @@ +/* + * The swiss theme: paper, ink, one accent, hairlines, Inter and JetBrains + * Mono. + */ +:root { + --sw-paper: #edeff1; + --sw-paper-2: #f7f8f9; + --sw-ink: #0f1115; + --sw-ink-2: #565b66; + --sw-hair: #c3c8cf; + --sw-hair-2: #dde1e6; + --sw-accent: #0b5fff; + --sw-accent-deep: #0a3f9e; + --sw-accent-wash: rgba(11, 95, 255, 0.1); + --sw-cyan: #0a8fe0; + --sw-cyan-overlay: rgba(0, 168, 255, 0.22); + --sw-red: #d6402b; + --sw-red-wash: rgba(214, 64, 43, 0.12); + /* Open questions: an amber that reads as unresolved, not as an error. */ + --sw-question: #9a5b00; + --sw-question-wash: #fbf1df; + --sw-question-line: #e0a948; + --sw-sans: "Inter Variable", "Helvetica Neue", Helvetica, Arial, sans-serif; + --sw-mono: + "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --sw-ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --header-height: 3rem; + --panel-head: 2rem; + /* How long a fold eases; `useFolds` waits as long before it stops easing the layout. */ + --fold-duration: 260ms; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +/* The interface is not text to copy; the documentation and the editors are. */ +body { + user-select: none; + -webkit-user-select: none; + background: var(--sw-paper); + color: var(--sw-ink); + font-family: var(--sw-sans); + font-size: 14px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; +} + +/* -- App frame ----------------------------------------------------------- */ + +.app { + display: grid; + grid-template-rows: var(--header-height) minmax(0, 1fr); + height: 100%; +} + +.header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 1.25rem; + padding: 0 1.25rem; + border-bottom: 1px solid var(--sw-hair); +} + +.header__wordmark { + display: block; + height: 1.1rem; + width: auto; + color: var(--sw-ink-2); + opacity: 0.55; +} + +.header__title { + margin: 0; + overflow: hidden; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.72rem; + font-weight: 500; + letter-spacing: 0.09em; + text-transform: uppercase; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* The joining word stays lowercase between the two names. */ +.header__title i { + margin: 0 0.1em; + color: color-mix(in oklab, var(--sw-ink-2) 55%, var(--sw-paper)); + font-style: normal; + font-weight: 400; + letter-spacing: 0.02em; + text-transform: none; +} + +/* + * The view switch: a rounded track with the current view inked by a pill inset + * by the padding. The pill is the track's ::before, anchored to the pressed + * button, so it glides to whichever view is chosen; its radius nests inside + * the track's: the outer radius less the padding and the border. + */ +/* + * The track, the pill and the buttons share one height and one radius: a + * squircle where the browser draws one. The pill and the labels sit a step + * softer than ink, on a white track lifted by a faint shadow. + */ +.header__views { + --segment-height: 1.75rem; + --segment-radius: 0.7rem; + --segment-pill: color-mix(in oklab, var(--sw-ink) 70%, var(--sw-paper-2)); + --segment-label: color-mix(in oklab, var(--sw-ink-2) 75%, var(--sw-paper-2)); + position: relative; + display: inline-flex; + justify-self: end; + border-radius: var(--segment-radius); + corner-shape: squircle; + background: var(--sw-paper-2); + box-shadow: + 0 1px 2px rgba(15, 17, 21, 0.035), + 0 2px 6px rgba(15, 17, 21, 0.025); + isolation: isolate; +} + +@supports (corner-shape: squircle) { + .header__views { + --segment-radius: calc(var(--segment-height) / 2); + } +} + +.header__views::before { + content: ""; + position: absolute; + position-anchor: --current-view; + top: anchor(top); + right: anchor(right); + bottom: anchor(bottom); + left: anchor(left); + z-index: -1; + border-radius: var(--segment-radius); + corner-shape: squircle; + background: var(--segment-pill); + transition: + left 280ms var(--sw-ease), + right 280ms var(--sw-ease); +} + +.header__view { + height: var(--segment-height); + padding: 0 1.05rem; + border: none; + border-radius: var(--segment-radius); + corner-shape: squircle; + background: transparent; + color: var(--segment-label); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: color 280ms var(--sw-ease); +} + +.header__view:hover { + color: var(--sw-ink-2); +} + +.header__view[aria-pressed="true"] { + anchor-name: --current-view; + color: var(--sw-paper-2); +} + +.header__view:focus-visible { + outline: 1px solid var(--sw-accent); + outline-offset: 1px; +} + +@media (prefers-reduced-motion: reduce) { + .header__views::before { + transition: none; + } +} + +.header__example { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.header__reset { + display: inline-flex; + align-items: center; + gap: 0.4rem; + height: 1.75rem; + border-radius: 0.55rem; + padding: 0 0.65rem; + border: 1px solid var(--sw-hair); + background: var(--sw-paper-2); + color: var(--sw-ink-2); + font: inherit; + font-size: 0.78rem; + cursor: pointer; + animation: header-reset-in 200ms var(--sw-ease); + transition: + color 160ms var(--sw-ease), + border-color 160ms var(--sw-ease); +} + +.header__reset:hover { + border-color: var(--sw-ink-2); + color: var(--sw-ink); +} + +.header__reset:focus-visible { + outline: 1px solid var(--sw-accent); + outline-offset: 1px; +} + +.header__reset-icon { + width: 0.75rem; + height: 0.75rem; +} + +@keyframes header-reset-in { + from { + opacity: 0; + transform: translateX(0.25rem); + } +} + +.workspace { + min-height: 0; +} + +/* The grid's display and templates are inline; the cells only need to clip. */ +.workspace__grid [data-cell] { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* A resizable group fills its cell; a fill is a positioned box an editor stretches over. */ +.stack { + height: 100%; +} + +.fill { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +/* Folds: the layout eases only while a toggle runs, never under a drag. */ +.workspace__grid--easing { + transition: + grid-template-columns var(--fold-duration) var(--sw-ease), + grid-template-rows var(--fold-duration) var(--sw-ease); +} + +.stack--easing > [data-panel] { + transition: flex-grow var(--fold-duration) var(--sw-ease); +} + +/* -- Panel: a head with the title over the body; the head of a folding panel is a toggle -- */ + +.panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* Panel heads sit halfway between the page's paper and the panels' lighter body; their title and chevron a step softer than ink-2. */ +.panel__head { + --panel-label: color-mix(in oklab, var(--sw-ink-2) 75%, var(--sw-paper-2)); + display: flex; + background: color-mix(in oklab, var(--sw-paper) 50%, var(--sw-paper-2)); + flex: 0 0 var(--panel-head); + align-items: center; + height: var(--panel-head); + padding: 0 0.75rem; + border-bottom: 1px solid var(--sw-hair-2); + overflow: hidden; + white-space: nowrap; + transition: border-color var(--fold-duration) var(--sw-ease); +} + +.panel__title { + overflow: hidden; + color: var(--panel-label); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + line-height: 1; + text-overflow: ellipsis; + text-transform: uppercase; +} + +.panel__toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + height: 1.5rem; + margin-left: -0.35rem; + padding: 0 0.35rem; + border: none; + border-radius: 2px; + background: transparent; + color: var(--panel-label); + cursor: pointer; + transition: + background-color 160ms var(--sw-ease), + color 160ms var(--sw-ease); +} + +.panel__toggle:hover { + background: var(--sw-hair-2); + color: var(--sw-ink); +} + +.panel__toggle:hover .panel__title { + color: var(--sw-ink); +} + +.panel__toggle:focus-visible { + outline: 1px solid var(--sw-accent); + outline-offset: 0; +} + +/* The chevron points toward the edge the panel folds against, and turns back once folded. */ +.panel__chevron { + --turn: 45deg; + --shift-x: 0rem; + --shift-y: -0.1rem; + flex: 0 0 auto; + width: 0.42rem; + height: 0.42rem; + border-right: 1.25px solid currentColor; + border-bottom: 1.25px solid currentColor; + transform: translate(var(--shift-x), var(--shift-y)) rotate(var(--turn)); + transition: transform var(--fold-duration) var(--sw-ease); +} + +.panel__chevron[data-points="down"] { + --turn: 45deg; + --shift-x: 0rem; + --shift-y: -0.1rem; +} + +.panel__chevron[data-points="up"] { + --turn: -135deg; + --shift-x: 0rem; + --shift-y: 0.1rem; +} + +.panel__chevron[data-points="left"] { + --turn: 135deg; + --shift-x: 0.1rem; + --shift-y: 0rem; +} + +.panel__chevron[data-points="right"] { + --turn: -45deg; + --shift-x: -0.1rem; + --shift-y: 0rem; +} + +.panel__body { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + background: var(--sw-paper-2); + transition: + opacity 220ms var(--sw-ease), + visibility 0s; +} + +/* Folded, the head stays with its title and the body fades out. */ +/* Controls at the right of a panel's head; a folded panel hides them. */ +.panel__actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; + padding-left: 0.75rem; +} + +.panel[data-collapsed="true"] .panel__actions { + display: none; +} + +/* A switch: its label in the head's mono capitals, then a pill whose knob slides right when on. */ +.switch { + --switch-width: 1.6rem; + --switch-height: 0.9rem; + --switch-inset: 2px; + --switch-knob: calc(var(--switch-height) - 2 * var(--switch-inset)); + + display: inline-flex; + align-items: center; + gap: 0.5rem; + height: 1.5rem; + padding: 0; + border: none; + background: none; + /* In a panel head, the label takes the head's title tone. */ + color: var(--panel-label, var(--sw-ink-2)); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: color 160ms var(--sw-ease); +} + +.switch:hover:not(:disabled) { + color: var(--sw-ink); +} + +.switch:disabled { + opacity: 0.45; + cursor: default; +} + +.switch:focus-visible { + outline: none; +} + +.switch__track { + position: relative; + flex: 0 0 auto; + width: var(--switch-width); + height: var(--switch-height); + border-radius: calc(var(--switch-height) / 2); + background: var(--sw-hair); + transition: background-color 180ms var(--sw-ease); +} + +.switch__track::after { + content: ""; + position: absolute; + top: var(--switch-inset); + left: var(--switch-inset); + width: var(--switch-knob); + height: var(--switch-knob); + border-radius: 50%; + background: var(--sw-paper-2); + box-shadow: 0 1px 2px rgba(15, 17, 21, 0.25); + transition: translate 200ms var(--sw-ease); +} + +.switch[aria-checked="true"] .switch__track { + background: var(--sw-accent); +} + +.switch[aria-checked="true"] .switch__track::after { + translate: calc(var(--switch-width) - var(--switch-knob) - 2 * var(--switch-inset)) 0; +} + +.switch:focus-visible .switch__track { + outline: 1px solid var(--sw-accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .switch__track, + .switch__track::after { + transition: none; + } +} + +.panel[data-collapsed="true"] .panel__head { + border-bottom-color: transparent; +} + +.panel[data-collapsed="true"] .panel__body { + opacity: 0; + visibility: hidden; + transition: + opacity 160ms var(--sw-ease), + visibility 0s linear 160ms; +} + +/* Folded against a side, the head turns into a strip: the chevron on top, the title upright under it. */ +.panel[data-stack="horizontal"][data-collapsed="true"] { + flex-direction: row; +} + +.panel[data-stack="horizontal"][data-collapsed="true"] .panel__head { + flex: 0 0 var(--panel-head); + align-items: flex-start; + justify-content: center; + width: var(--panel-head); + height: 100%; + padding: 0.3rem 0; + border-bottom: none; +} + +.panel[data-stack="horizontal"][data-collapsed="true"] .panel__toggle { + flex-direction: column; + gap: 0.7rem; + height: auto; + margin: 0; + padding: 0.5rem 0.3rem; +} + +.panel[data-stack="horizontal"][data-collapsed="true"] .panel__title { + writing-mode: vertical-rl; +} + +/* -- Gridlines: a 1px hairline; a translucent blue-cyan band grows 2px past each side on hover or drag -- */ + +.gridline { + position: relative; + z-index: 1; + background: var(--sw-hair); + transition: background-color 320ms var(--sw-ease); +} + +.gridline--column { + width: 1px; +} + +.gridline--row { + height: 1px; +} + +.gridline::after { + content: ""; + position: absolute; + background: var(--sw-cyan-overlay); + opacity: 0; + transition: + opacity 320ms var(--sw-ease), + transform 360ms var(--sw-ease); + pointer-events: none; +} + +.gridline--column::after { + top: 0; + bottom: 0; + left: -2px; + right: -2px; + transform: scaleX(0.2); +} + +.gridline--row::after { + left: 0; + right: 0; + top: -2px; + bottom: -2px; + transform: scaleY(0.2); +} + +/* The delay sits on the hovered state alone, so entering waits 70ms and leaving is immediate. */ +.gridline[data-separator="hover"], +.gridline[data-separator="active"], +.gridline[data-separator="focus"] { + background: var(--sw-cyan); + transition-delay: 70ms; +} + +.gridline[data-separator="hover"]::after, +.gridline[data-separator="active"]::after, +.gridline[data-separator="focus"]::after { + opacity: 1; + transform: none; + transition-delay: 70ms; +} + +.gridline:focus-visible { + outline: none; +} + +/* -- Marks ---------------------------------------------------------------- */ + +.kicker { + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +/* -- Example picker: a button, and a popover listing the examples up the ladder -- */ + +/* The trigger centres its label and chevron; inside the label, the feature and the net share a baseline. */ +.picker__trigger { + anchor-name: --example-picker; + display: inline-flex; + align-items: center; + gap: 0.55rem; + max-width: 24rem; + height: 1.75rem; + padding: 0 0.6rem 0 0.75rem; + border: 1px solid var(--sw-hair); + border-radius: 0.55rem; + background: var(--sw-paper-2); + color: var(--sw-ink); + font: inherit; + font-size: 0.8rem; + line-height: 1; + cursor: pointer; + transition: border-color 160ms var(--sw-ease); +} + +.picker__label { + display: inline-flex; + align-items: baseline; + gap: 0.55rem; + min-width: 0; +} + +.picker__trigger:hover { + border-color: var(--sw-ink-2); +} + +.picker__trigger:focus-visible { + outline: 1px solid var(--sw-accent); + outline-offset: 1px; +} + +.picker__feature { + overflow: hidden; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +.picker__net { + flex: 0 0 auto; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.68rem; +} + +.picker__chevron { + flex: 0 0 auto; + align-self: center; + width: 0.4rem; + height: 0.4rem; + margin: 0 0.15rem 0 0.1rem; + border-right: 1.25px solid var(--sw-ink-2); + border-bottom: 1.25px solid var(--sw-ink-2); + /* Rotated, the two borders draw a V whose ink sits low in its box: lift it to the centre. */ + transform: translateY(-0.1rem) rotate(45deg); + transition: transform 220ms var(--sw-ease); +} + +.picker__trigger:has(+ .picker__list:popover-open) .picker__chevron { + transform: translateY(0.1rem) rotate(-135deg); +} + +/* The list sits under the button, right-aligned to it, and fades in. The footer closes it at the bottom. */ +.picker__list { + position-anchor: --example-picker; + inset: auto; + top: anchor(bottom); + right: anchor(right); + width: min(20rem, calc(100vw - 2rem)); + max-height: min(80vh, 32rem); + margin: 0.35rem 0 0; + padding: 0.25rem 0.25rem 0; + border: 1px solid var(--sw-hair); + border-radius: 0.75rem; + background: var(--sw-paper-2); + color: var(--sw-ink); + box-shadow: 0 12px 32px rgba(15, 17, 21, 0.14); + overflow-y: auto; + opacity: 0; + translate: 0 -0.25rem; + transition: + opacity 180ms var(--sw-ease), + translate 220ms var(--sw-ease), + overlay 220ms allow-discrete, + display 220ms allow-discrete; +} + +.picker__list:popover-open { + opacity: 1; + translate: 0 0; +} + +@starting-style { + .picker__list:popover-open { + opacity: 0; + translate: 0 -0.25rem; + } +} + +/* A rung's title recedes behind its examples: small caps in a lighter grey, in line with the features. */ +.picker__group-title { + padding: 0.6rem 0.6rem 0.2rem 1.6rem; + color: color-mix(in oklab, var(--sw-ink-2) 75%, var(--sw-paper-2)); + font-family: var(--sw-mono); + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.picker__option { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + align-items: center; + gap: 0.25rem; + width: 100%; + height: 1.875rem; + padding: 0 0.6rem 0 0.35rem; + border: none; + border-radius: 0.5rem; + background: transparent; + color: var(--sw-ink); + font: inherit; + font-size: 0.82rem; + font-weight: 500; + text-align: left; + cursor: pointer; + transition: background-color 140ms var(--sw-ease); +} + +/* The pointer greys a row and the keyboard washes it in the accent, so the two never look alike. */ +.picker__option:hover { + background: var(--sw-hair-2); +} + +.picker__option:focus-visible { + outline: none; + background: var(--sw-accent-wash); +} + +/* The open example: a check and a heavier weight, never a fill, so hover and focus still show on it. */ +.picker__option[aria-selected="true"] { + font-weight: 600; +} + +.picker__option .picker__feature { + font-weight: inherit; +} + +/* Two borders of a box turned a quarter back draw the check, as two borders turned forward draw the chevron. */ +.picker__check { + justify-self: center; + width: 0.5rem; + height: 0.28rem; + border-bottom: 1.5px solid var(--sw-accent); + border-left: 1.5px solid var(--sw-accent); + rotate: -45deg; + translate: 0 -0.08rem; + opacity: 0; +} + +.picker__option[aria-selected="true"] .picker__check { + opacity: 1; +} + +/* The net and the summary of the example under the pointer, else under the keyboard focus. */ +.picker__about { + position: sticky; + bottom: 0; + min-height: 4.4rem; + margin: 0.25rem -0.25rem 0; + padding: 0.55rem 0.95rem 0.6rem; + border-top: 1px solid var(--sw-hair-2); + background: var(--sw-paper-2); + color: var(--sw-ink-2); + font-size: 0.76rem; + line-height: 1.4; +} + +.picker__about-net { + display: block; + margin-bottom: 0.15rem; + color: var(--sw-ink); + font-family: var(--sw-mono); + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@media (prefers-reduced-motion: reduce) { + .picker__list, + .picker__chevron, + .picker__option { + transition: none; + } +} + +.note { + padding: 0.75rem; + color: var(--sw-ink-2); + font-size: 0.85rem; +} + +/* -- Editors --------------------------------------------------------------- */ + +.monaco-host { + position: absolute; + inset: 0; + user-select: text; + -webkit-user-select: text; +} + +/* + * Expanded chevrons in the gutter rest at a low opacity instead of Monaco's + * zero, and come up while the pointer is over the gutter. Collapsed ones stay + * at full, as Monaco has them. One class more than Monaco's own rules, so the + * order the stylesheets load in does not matter. + */ +.monaco-host .margin-view-overlays .codicon:is(.codicon-folding-expanded, .codicon-folding-manual-expanded) { + opacity: 0.3; +} + +.monaco-host .margin-view-overlays:hover .codicon:is(.codicon-folding-expanded, .codicon-folding-manual-expanded) { + opacity: 1; +} + +.monaco-editor .monaco-hover, +.monaco-editor .suggest-widget { + font-family: var(--sw-sans); +} + +/* Completions and their details panel: slightly rounded, barely lifted. */ +.monaco-editor .suggest-widget, +.monaco-editor .suggest-details { + overflow: hidden; + border-radius: 0.35rem; + box-shadow: 0 2px 6px rgba(15, 17, 21, 0.06); +} + +/* + * "No suggestions." and "Loading...": a quiet note rather than a row. Monaco + * sizes the widget inline to one row, so while it shows a message it takes + * the message's height instead. + */ +.monaco-editor .suggest-widget.message { + height: auto !important; +} + +.monaco-editor .suggest-widget > .message { + padding-block: 3px; + color: color-mix(in oklab, var(--sw-ink-2) 65%, var(--sw-paper-2)); + font-size: 12px; +} + +/* Each row's description at full strength instead of Monaco's 70%. */ +.monaco-editor .suggest-widget .monaco-list .monaco-list-row .details-label { + color: var(--sw-ink-2); + opacity: 1; +} + +/* + * Monaco sizes the details panel inline from a one-line measurement, which + * cuts off a description that wraps. The descriptions are a line or two, so + * the panel takes its content's height instead, capped. + */ +.monaco-editor .suggest-details-container, +.monaco-editor .suggest-details, +.monaco-editor .suggest-details > .monaco-scrollable-element, +.monaco-editor .suggest-details .body { + height: auto !important; + max-height: 12rem; +} + +/* The panel's text clear of the border, with the close button beside its first line. */ +.monaco-editor .suggest-details .docs { + padding: 7px 32px 8px 10px !important; + color: var(--sw-ink); + font-family: var(--sw-sans); + font-size: 12.5px; + line-height: 1.45; +} + +.monaco-editor .suggest-details > .monaco-scrollable-element > .body > .header > .codicon-close { + top: 8px; + right: 8px; + color: var(--sw-ink-2); +} + +/* Hover cards: paper-grey text on ink, roomier, with the mono face for code. */ +.monaco-editor .monaco-hover { + --monaco-monospace-font: var(--sw-mono); + color: #c3c8cf; +} + +/* Monaco writes the editor's font size and line height inline on this node. */ +.monaco-editor .monaco-hover .monaco-hover-content { + font-size: 13px !important; + line-height: 1.45 !important; +} + +.monaco-editor .monaco-hover .hover-contents:not(.html-hover-contents) { + padding: 8px 12px; +} + +.monaco-editor .monaco-hover strong { + color: #f7f8f9; +} + +.monaco-editor .monaco-hover code { + font-family: var(--sw-mono); + font-size: 12px; + color: #edeff1; +} + +/* -- Module view: the editor, the files beside it, the diagnostics under both -- */ + +.module { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.module__main { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +/* -- Compiler options: sections of options, each with what it does; a dependent option nests under its parent -- */ + +.options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr)); + align-content: start; + gap: 1.25rem 1.75rem; + height: 100%; + padding: 0.85rem 0.85rem 1.25rem; + overflow-y: auto; +} + +.options__section { + display: flex; + flex-direction: column; + gap: 0.65rem; + min-width: 0; +} + +.options__title { + margin: 0; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.66rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.options__about { + margin: -0.35rem 0 0.1rem; + color: var(--sw-ink-2); + font-size: 0.74rem; + line-height: 1.35; +} + +.option { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + align-items: center; + gap: 0.25rem 0.6rem; + transition: opacity 160ms var(--sw-ease); +} + +/* An option that only matters through the one above hangs from it on a hairline. */ +.option[data-nested="true"] { + margin-left: 0.55rem; + padding-left: 0.7rem; + border-left: 1px solid var(--sw-hair); +} + +.option__label { + color: var(--sw-ink); + font-size: 0.8rem; + font-weight: 500; +} + +.option__control { + width: 100%; + height: 1.6rem; + padding: 0 0.5rem; + border: 1px solid var(--sw-hair); + border-radius: 2px; + background: var(--sw-paper-2); + color: var(--sw-ink); + font-family: var(--sw-mono); + font-size: 0.72rem; + transition: border-color 160ms var(--sw-ease); +} + +.option__control--select { + appearance: none; + padding-right: 1.5rem; + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23565b66' stroke-width='1.2'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E") + no-repeat right 0.5rem center / 0.55rem auto var(--sw-paper-2); + cursor: pointer; +} + +.option__control:hover:not(:disabled) { + border-color: var(--sw-ink-2); +} + +.option__control:focus-visible { + outline: none; + border-color: var(--sw-accent); +} + +/* Placed and weighted to win over hover and focus, which a field being typed in has. */ +.option__control[aria-invalid="true"]:not(:disabled) { + border-color: var(--sw-red); +} + +.option__control:disabled { + cursor: not-allowed; +} + +.option__hint { + grid-column: 1 / -1; + margin: 0; + color: var(--sw-ink-2); + font-size: 0.72rem; + line-height: 1.4; +} + +/* Off for this net: label and control fade, and the hint says why. */ +.option[data-disabled="true"] .option__label, +.option[data-disabled="true"] .option__control { + opacity: 0.45; +} + +.option[data-disabled="true"] .option__hint { + font-style: italic; +} + +/* -- File list: the emitted files, in the small panel beside the module -- */ + +.file-list { + display: flex; + flex-direction: column; + height: 100%; + padding: 0.4rem 0; + overflow-y: auto; +} + +.file-list__item { + flex: 0 0 auto; + overflow: hidden; + padding: 0.3rem 0.75rem; + border: none; + background: transparent; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.7rem; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + transition: + color 160ms var(--sw-ease), + background-color 160ms var(--sw-ease); +} + +.file-list__item:hover { + color: var(--sw-ink); +} + +.file-list__item[aria-current="true"] { + color: var(--sw-ink); + background: var(--sw-paper); + box-shadow: inset 2px 0 0 var(--sw-accent); +} + +.diagnostics { + flex: 0 0 auto; + max-height: 30%; + margin: 0; + padding: 0.35rem 0; + border-top: 1px solid var(--sw-hair-2); + list-style: none; + overflow-y: auto; + background: var(--sw-paper); +} + +.diagnostic { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.15rem 0.75rem; + font-size: 0.78rem; + line-height: 1.35; +} + +.diagnostic__code { + flex: 0 0 auto; + font-family: var(--sw-mono); + font-size: 0.7rem; + color: var(--sw-red); +} + +.diagnostic[data-severity="warning"] .diagnostic__code { + color: var(--sw-ink-2); +} + +.diagnostic__message { + flex: 1 1 auto; + min-width: 0; +} + +.diagnostic__item { + color: var(--sw-ink-2); + font-style: normal; + font-family: var(--sw-mono); + font-size: 0.7rem; +} + +.diagnostic__line { + flex: 0 0 auto; + padding: 0; + border: none; + background: none; + color: var(--sw-accent); + font: inherit; + font-size: 0.72rem; + cursor: pointer; +} + +.diagnostic__line:hover { + text-decoration: underline; +} + +/* -- Line decorations ------------------------------------------------------ */ + +.monaco-editor .line-lit { + background: var(--sw-accent-wash); + animation: line-lit-in 160ms var(--sw-ease); +} + +@keyframes line-lit-in { + from { + background-color: transparent; + } +} + +.monaco-editor .line-error { + background: var(--sw-red-wash); +} + +.monaco-editor .line-warning { + background: rgba(86, 91, 102, 0.08); +} + +.monaco-editor .line-error-mark { + background: var(--sw-red); + width: 2px !important; + margin-left: 2px; +} + +.monaco-editor .line-warning-mark { + background: var(--sw-ink-2); + width: 2px !important; + margin-left: 2px; +} + +/* -- Net preview ----------------------------------------------------------- */ + +.net-frame { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: 0.75rem; + overflow: hidden; +} + +.net { + display: block; + width: 100%; + height: 100%; + font-family: var(--sw-mono); +} + +.net__shape { + fill: var(--sw-paper-2); + stroke: var(--sw-ink); + stroke-width: 1.25; + transition: + stroke 200ms var(--sw-ease), + stroke-width 200ms var(--sw-ease), + fill 200ms var(--sw-ease); +} + +.net__node { + cursor: default; +} + +.net__node[data-lit="true"] .net__shape { + fill: var(--sw-accent-wash); + stroke: var(--sw-accent); + stroke-width: 2; +} + +.net__tokens circle { + fill: var(--sw-ink); + transition: fill 200ms var(--sw-ease); +} + +.net__node[data-lit="true"] .net__tokens circle { + fill: var(--sw-accent); +} + +.net__count { + fill: var(--sw-ink); + font-size: 13px; + font-weight: 600; +} + +.net__label { + fill: var(--sw-ink); + font-size: 11px; + font-weight: 500; + transition: fill 200ms var(--sw-ease); +} + +.net__node[data-lit="true"] .net__label { + fill: var(--sw-accent); +} + +.net__detail, +.net__weight { + fill: var(--sw-ink-2); + font-size: 9px; + letter-spacing: 0.02em; +} + +.net__weight { + paint-order: stroke fill; + stroke: var(--sw-paper-2); + stroke-width: 3px; +} + +.net__line { + fill: none; + stroke: var(--sw-ink-2); + stroke-width: 1.25; + transition: + stroke 200ms var(--sw-ease), + stroke-width 200ms var(--sw-ease); +} + +.net__arc[data-kind="read"] .net__line { + stroke-dasharray: 4 3; +} + +.net__arc[data-lit="true"] .net__line { + stroke: var(--sw-accent); + stroke-width: 1.75; +} + +.net__inhibit { + fill: var(--sw-paper-2); + stroke: context-stroke; + stroke-width: 1.2; +} + +/* -- Documentation: a reading column with room between its parts ---------- */ + +.docs { + height: 100%; + padding: 1.1rem 1.35rem 2rem; + overflow-y: auto; + color: #22262c; + font-size: 0.875rem; + line-height: 1.62; + user-select: text; + -webkit-user-select: text; +} + +.docs > * { + max-width: 60ch; +} + +/* The net a page uses, as a label over its title. */ +.docs .docs__net { + margin: 0 0 0.3rem; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs h2 { + margin: 0 0 0.9rem; + color: var(--sw-ink); + font-size: 1.15rem; + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.25; +} + +.docs h3 { + margin: 1.5rem 0 0.4rem; + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs p { + margin: 0 0 0.95rem; +} + +.docs p:last-child { + margin-bottom: 0; +} + +.docs ul { + margin: 0 0 0.95rem; + padding-left: 1.1rem; +} + +.docs li { + margin: 0 0 0.35rem; +} + +.docs li::marker { + color: var(--sw-ink-2); +} + +.docs strong { + color: var(--sw-ink); + font-weight: 600; +} + +/* Code is quoted as written: no ligature turns != or >= into a symbol the source does not have. */ +.docs code { + font-variant-ligatures: none; + padding: 0.05em 0.3em; + border-radius: 2px; + background: var(--sw-paper); + color: var(--sw-ink); + font-family: var(--sw-mono); + font-size: 0.8em; + white-space: nowrap; +} + +.docs .figure { + max-width: 22rem; + margin: 1.1rem 0 1.25rem; +} + +/* A note stands apart from the reading: a hairline at its side, the text a step quieter. */ +.docs blockquote { + margin: 1.5rem 0 0; + padding: 0.7rem 0.9rem; + border-left: 2px solid var(--sw-hair); + background: var(--sw-paper); + color: var(--sw-ink-2); + font-size: 0.8rem; + line-height: 1.55; +} + +.docs blockquote p { + margin: 0; +} + +/* Math: MathJax SVG at the text's size; a display formula gets a line of its own. */ +.docs mjx-container { + font-size: 1.02em; +} + +.docs mjx-container:not([display="true"]) svg { + vertical-align: -0.22em; +} + +.docs mjx-container[display="true"] { + display: block; + max-width: 100%; + margin: 0.85rem 0 1.05rem; + padding: 0.25rem 0; + overflow-x: auto; + overflow-y: hidden; + text-align: center; +} + +/* Open questions close every page: amber boxes, each naming who can answer it. */ +.open-question { + margin: 0 0 0.75rem; + padding: 0.6rem 0.85rem 0.7rem; + border-left: 2px solid var(--sw-question-line); + border-radius: 0 2px 2px 0; + background: var(--sw-question-wash); +} + +.open-question__owner { + display: block; + margin-bottom: 0.3rem; + color: var(--sw-question); + font-family: var(--sw-mono); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; +} + +.open-question__body { + color: #2b2f36; + font-size: 0.84rem; + line-height: 1.55; +} + +.open-question__body p { + margin: 0; +} + +.open-question__body p + p { + margin-top: 0.5rem; +} + +.docs .open-question code { + background: rgba(255, 255, 255, 0.65); +} + +/* Tables: hairline rows, a mono header, cells that wrap inside the column. */ +.docs table { + width: 100%; + max-width: 60ch; + margin: 0.4rem 0 1.1rem; + border-collapse: collapse; + font-size: 0.78rem; + line-height: 1.45; +} + +.docs th, +.docs td { + padding: 0.4rem 0.6rem 0.4rem 0; + border-bottom: 1px solid var(--sw-hair-2); + text-align: left; + vertical-align: top; + overflow-wrap: break-word; +} + +.docs th { + border-bottom-color: var(--sw-hair); + color: var(--sw-ink-2); + font-family: var(--sw-mono); + font-size: 0.68rem; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.docs tbody th { + width: 22%; + border-bottom-color: var(--sw-hair-2); + letter-spacing: 0; + text-transform: none; +} + +.docs td code { + white-space: normal; +} diff --git a/pocs/zeroth-playground/playground/ui/panel.tsx b/pocs/zeroth-playground/playground/ui/panel.tsx new file mode 100644 index 00000000..33d7a80a --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/panel.tsx @@ -0,0 +1,68 @@ +/** + * How a panel folds: toward the edge of its stack it sits against. `bottom` + * is a panel in a vertical stack, which folds to its head row; `start` and + * `end` are a panel in a horizontal stack, which folds to a narrow strip with + * its title written upright. + */ +export type PanelFold = { + edge: "bottom" | "start" | "end"; + collapsed: boolean; + onToggle: () => void; +}; + +type PanelProps = { + title: string; + /** Absent for a panel that does not fold. */ + fold?: PanelFold; + /** Controls at the right of the head, hidden while the panel is folded. */ + actions?: React.ReactNode; + children: React.ReactNode; +}; + +/** Where the chevron points: toward the edge while open, back out once folded. */ +function chevronDirection({ edge, collapsed }: PanelFold): "up" | "down" | "left" | "right" { + switch (edge) { + case "bottom": + return collapsed ? "up" : "down"; + case "start": + return collapsed ? "right" : "left"; + case "end": + return collapsed ? "left" : "right"; + } +} + +/** + * The frame of every view: a head with the title over the body. A panel that + * folds puts its title in a toggle whose chevron turns as it opens and + * closes. Folded, the head stays and the title with it, written upright when + * the panel folds against a side; the body fades out and takes no focus. + * Actions sit at the right of the head. + */ +export const Panel: React.FC = ({ title, fold, actions, children }) => { + const collapsed = fold?.collapsed ?? false; + const stack = fold === undefined || fold.edge === "bottom" ? "vertical" : "horizontal"; + return ( +
    +
    + {fold === undefined ? ( + {title} + ) : ( + + )} + {actions === undefined ? null :
    {actions}
    } +
    +
    + {children} +
    +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/ui/resize-target.ts b/pocs/zeroth-playground/playground/ui/resize-target.ts new file mode 100644 index 00000000..0c2c8c44 --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/resize-target.ts @@ -0,0 +1,2 @@ +/** The hit target of every separator and gridline: 4px past each side of its 1px line, wider for touch. */ +export const RESIZE_TARGET = { fine: 9, coarse: 27 }; diff --git a/pocs/zeroth-playground/playground/ui/switch.tsx b/pocs/zeroth-playground/playground/ui/switch.tsx new file mode 100644 index 00000000..7c78b45f --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/switch.tsx @@ -0,0 +1,26 @@ +type SwitchProps = { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + /** Said on hover, to name what the switch does. */ + title?: string; +}; + +/** An on/off switch: a small track whose knob slides over, and its label beside it. */ +export const Switch: React.FC = ({ label, checked, onChange, disabled = false, title }) => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/ui/use-folds.ts b/pocs/zeroth-playground/playground/ui/use-folds.ts new file mode 100644 index 00000000..462fb70b --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/use-folds.ts @@ -0,0 +1,61 @@ +import { useRef, useState } from "react"; + +import type { PanelFold } from "./panel"; + +/** What a collapsible grid track or group panel of react-resizable-panels lets a toggle do. */ +export type Foldable = { + isCollapsed: () => boolean; + collapse: () => void; + expand: () => void; +}; + +/** How long a fold eases; the stylesheet's `--fold-duration` says the same. */ +const FOLD_MS = 260; + +/** + * The folds of a set of collapsible tracks or panels, as React state. The + * library owns the sizes; `sync`, called on every layout change, mirrors which + * ones are collapsed, a drag past a threshold included. A toggle folds or + * unfolds one and raises `easing` while the fold animates, so the container + * transitions its layout then, and never under a drag. + */ +export function useFolds( + keys: readonly Key[], + handleOf: (key: Key) => Foldable | null | undefined, +) { + const [collapsed, setCollapsed] = useState([]); + const [easing, setEasing] = useState(false); + const easingTimer = useRef(undefined); + + function sync() { + const next = keys.filter((key) => handleOf(key)?.isCollapsed() ?? false); + setCollapsed((current) => + current.length === next.length && current.every((key, index) => key === next[index]) + ? current + : next, + ); + } + + function toggle(key: Key) { + const handle = handleOf(key); + if (handle === null || handle === undefined) { + return; + } + setEasing(true); + // A toggle during an earlier fold eases for its own full duration. + window.clearTimeout(easingTimer.current); + easingTimer.current = window.setTimeout(() => setEasing(false), FOLD_MS); + if (handle.isCollapsed()) { + handle.expand(); + } else { + handle.collapse(); + } + } + + /** The fold a `Panel` takes for one key, folding against `edge`. */ + function foldOf(key: Key, edge: PanelFold["edge"]): PanelFold { + return { edge, collapsed: collapsed.includes(key), onToggle: () => toggle(key) }; + } + + return { foldOf, sync, easing }; +} diff --git a/pocs/zeroth-playground/pnpm-lock.yaml b/pocs/zeroth-playground/pnpm-lock.yaml new file mode 100644 index 00000000..c2d5c647 --- /dev/null +++ b/pocs/zeroth-playground/pnpm-lock.yaml @@ -0,0 +1,2831 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource-variable/inter': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/jetbrains-mono': + specifier: 5.3.0 + version: 5.3.0 + elkjs: + specifier: 0.12.0 + version: 0.12.0 + js-yaml: + specifier: 5.4.2 + version: 5.4.2 + monaco-editor: + specifier: 0.57.0 + version: 0.57.0 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + react-resizable-panels: + specifier: 4.14.1 + version: 4.14.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@mdx-js/rollup': + specifier: 3.1.1 + version: 3.1.1(rollup@4.63.6) + '@types/mdx': + specifier: 2.0.14 + version: 2.0.14 + '@types/node': + specifier: 24.19.1 + version: 24.19.1 + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.1 + version: 6.1.1(oxc-transform-react@0.152.0)(vite@8.3.2(@types/node@24.19.1)) + oxc-transform-react: + specifier: 0.152.0 + version: 0.152.0 + playwright: + specifier: 1.63.0 + version: 1.63.0 + rehype-mathjax: + specifier: 7.1.0 + version: 7.1.0 + remark-math: + specifier: 6.0.0 + version: 6.0.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@24.19.1) + vite-plugin-singlefile: + specifier: 2.3.3 + version: 2.3.3(rollup@4.63.6)(vite@8.3.2(@types/node@24.19.1)) + vitest: + specifier: 5.0.3 + version: 5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)) + +packages: + + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + + '@fontsource-variable/jetbrains-mono@5.3.0': + resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@mdx-js/rollup@3.1.1': + resolution: {integrity: sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw==} + peerDependencies: + rollup: '>=2' + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@oxc-transform-react/binding-android-arm-eabi@0.152.0': + resolution: {integrity: sha512-HrGZlhhyJUxz5tDWnsBliNrlhSVxM9lLF468EKab1uBBUMbMzfwHdO4aOHbI8x4EbI/gIzpMXqJbtWRwBLdwZw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform-react/binding-android-arm64@0.152.0': + resolution: {integrity: sha512-h+LtkA/nSQsfZdCe6c3F28236gIZhpl3Bu9MjwEI7X0J+CGpEEShmZuXw5b7lwqXQnhr5l1ZIIZ4NA5tU5FW+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform-react/binding-darwin-arm64@0.152.0': + resolution: {integrity: sha512-EycvN1vJ5gjer1+AD4Czi/Z0gpD4ud/oRbiwDV3BDOiuN5PVrR5Dz0oqkoKtE+/eKK6QqygEtZrZzc8ARJkLLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform-react/binding-darwin-x64@0.152.0': + resolution: {integrity: sha512-5YUXuvxXi+7GmZPho95qteMlG8WgIhCbpYOug8MhyuTZzYtrr8CDBsdMzheBgiRNj9lPkFvduhQ4MK+QVQ06CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform-react/binding-freebsd-x64@0.152.0': + resolution: {integrity: sha512-HZrLNspvHfOo1umeCkeskU7UBEXWjuQSoBGidHwjUQVOhsbDNp9k3oihXenEBFgShPycLEC11wAZSD3DtiIkXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform-react/binding-linux-arm-gnueabihf@0.152.0': + resolution: {integrity: sha512-qfPsGd0sbaUeLtQDojSf+reHYMBHWX+4mdq1lsARkRzGjoHCvstnTr+nKb7sTtGsdPYhbSgbPINI+h1V+KSF5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform-react/binding-linux-arm-musleabihf@0.152.0': + resolution: {integrity: sha512-lYpuYevetblWvNsylYb3BxKbXd2t+BhUqwIoa3/98zmWeLbB+VBfxV8APdZV9gbw2W04HknXKU386pt/5duvfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform-react/binding-linux-arm64-gnu@0.152.0': + resolution: {integrity: sha512-8eIPBuC+1WpE3Z7AiieAVhbkt6PLEbT4Rnm4SeVOMvIUKTml+s2PSzMu2qAUtFdIB4SFVshTj9SliturT3GHCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-arm64-musl@0.152.0': + resolution: {integrity: sha512-rv4dvLnqhgen1tqb7eoB/fNfD5fq+Bf3RTH9YuJ+8SNdqOk2TjAXFUdve0XtjD9Wost0fQ605bVXVrStVY/IEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-linux-ppc64-gnu@0.152.0': + resolution: {integrity: sha512-zP9xCGiJFyHkP6B7UtVQ99nfYXe8e7eqLqZv0C+Zr2jlhNuLMBsB77QuRczlhtRQqncRWzBI8BkQzy/Lo7GyaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-riscv64-gnu@0.152.0': + resolution: {integrity: sha512-INC57B7OIgMEoUe4NquHvg77tlYL4E6aMglhKs3FY5k5oB9/+hd2y0Xg1DVco5uDtkgilnSQRM5P0Ptz7CxXPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-riscv64-musl@0.152.0': + resolution: {integrity: sha512-4oPU+QTufHexk7GOGe/JapxAfYlU5EXUsN5J0IsBC9s3SsGUTTjpmXQYiDe4dkX5/BGerE+9mYDG94jNwuk99A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-linux-s390x-gnu@0.152.0': + resolution: {integrity: sha512-xt4qy3y+9s32F/pz7ymaf1VfDRI+cKwDnW2poFID2r51dC/gln3QGGDaqv2XLaZGQT5o4uGwIry+9CQY5AHNeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-x64-gnu@0.152.0': + resolution: {integrity: sha512-6va4CIMFzejx2i8KhLAYuunai7b85vRpZSLvuBR4bK2ywuTb6V0kJDFJwiKjWHQRNzEvRjbl887+jqa1rKl8QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform-react/binding-linux-x64-musl@0.152.0': + resolution: {integrity: sha512-l1QfEbsREILsAWIcmpEsEu9PKjmN6BKmE1Oe97QJDUjeJ3z5lBn5g2feC3nof3JJc5SsG8BiY4F+cm1yHRhmeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform-react/binding-openharmony-arm64@0.152.0': + resolution: {integrity: sha512-UmdDypmnG1WSZxzQXd8GfhbZQq//XsvMu5/mm+I1uCW3v+9UwpD1J7H+jg91W4xtRY1c0qAhUoMsZi+6vB7Qug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform-react/binding-win32-arm64-msvc@0.152.0': + resolution: {integrity: sha512-JgETFwNiqqB0BfTKMamnVF1zBMnburMoeqnyBgWwKj+JdEqgFqO/0G/O+eWUfNW4xPryjh6HGuq9eNHZLRYrjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform-react/binding-win32-ia32-msvc@0.152.0': + resolution: {integrity: sha512-j2oFkpEG89D38wVY43OtU1s9QUmq0C1+Dpp62PB+SDDTAMsUwU2HofnhK3h9iYTAs7wH/6YqzWOM4XjQ6g/Nmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': + resolution: {integrity: sha512-iFxYqUqkN/8D0migw/RVZoEvVcJhZ1NzJuRBal0Fgt7V1yJed0QQIHd719kL4OrVmX5f5u2tNZxJB1UJiQ0D/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.6': + resolution: {integrity: sha512-G6xF9OVRWsbHadMfoPNUDwW/Jt70QYG+ZDUvtYCFxjf3SPhqVAO0u9ShXZ7Cf5silVoRWyN4bDjCEKnJkXkw0w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.6': + resolution: {integrity: sha512-Us/kTH5e2anr1CQvO8MEq4eeCVcIXI3Ag7OFxDI30n2OW7Q7xWuxo8ItbYq28PG2Sm1NWmZzG9+SqVEaj25SFA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.6': + resolution: {integrity: sha512-fwaSNrSHp9PJuEh4bKtd0mYC+G3FeVDjSTmsSiSlIYu7TFCpxrfB6lk7Ao8cIbswFyklPB1ZDUV8LkLBM15VWw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.6': + resolution: {integrity: sha512-QR2tx26gCeGh4eAGnUasfnJuRc+d7Vc0Jo1/fYtKGuiKqCuhbVpbNEvqgYGBwGDZGyS04jj+zdHi9W8uvb1Sdw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.6': + resolution: {integrity: sha512-7E1wRJEW8r7WlqmCy58ugndWHrzoEV/pRt2x0D9el7Inra62+Cw00lYW9XhW6iUCtFjhNw49McsSCIzJ/nG8uw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.6': + resolution: {integrity: sha512-13KJeF+vDswMzEHmMIouCVcEQdov3CzAA3ZhM8QFmBJV/FZ/VMZI49UHDvPXTK+wokk86kYPmQ9Qb3xGGJbZoQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.6': + resolution: {integrity: sha512-hV+W1r8HM84PER4If94QuzrrD0DNk4CxobNhVUiz9ZiiWHDzJss4Jra2AYGH1a7GOTzjS1sIyZ35yYEhw/aq+g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.6': + resolution: {integrity: sha512-5dWs/GENZufRph8PEfl0TrNH/R3DHJA+VxF/dqlfV2oUZfxRtN3qmFXg2DZj8DYM8d+Vd3dgZtuVmoBE/nb2YQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.6': + resolution: {integrity: sha512-inQYLPVIUvYk+s7zpxQldqwrajUNs+t4TJjPGzsdPTvsT8gsQXVcW/sQFolHZ3YSWVk0nMMwU7TtRi0Ow+QMuw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.6': + resolution: {integrity: sha512-sH+0MV1HmDC1q7+bQnlN9ywKBmHqkA2EdD/hHYrrz654ybc+vNC1FRNyzuYKicMQVtZZ4X2jz0osJYmsOtnj4g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.6': + resolution: {integrity: sha512-mCECvRr6HGekdBc6dvHzUWubqEti4JyOqlNePrOzl4Wimk26iW5Zy9rld2mQbC9id1NYjfF8nHLFq5fr3CuOKQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.6': + resolution: {integrity: sha512-bb9rdoCPwM4DEsbOP3CJgFnvhSJ3XKCUYyUhxnlSs/XGT0DknpOYgLCPbK8VUzwl+cZGzFQm0KbBYRaSGZC2YQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.6': + resolution: {integrity: sha512-aIJRoGCHev45JSu7JMzVrMzu1NX3nQrXx1Vb/6y4AblxW7JpCMpk4e8C6lHzvSElxi3UEcMdFJldsuWQjV1ddA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.6': + resolution: {integrity: sha512-LvjnulezHjiaM5Ga9MPCDLTVddjvcrJhZc6vEz9fxeFRGPJsPAX9HuTuPTUQ3l8d+nYpZB/xWzUpuKJlLQm3VQ==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.6': + resolution: {integrity: sha512-YmhSBeYZwJ937s8tKpYAhms48N+tj7e+oFkbkVVxfMRdAiynK2AxqRapJXB4utyXxc/iYTkMfPRQ+7ZP0IO78w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.6': + resolution: {integrity: sha512-9+YukhzqTvJvzfDXJJzkURK9TDY01nofNH3pyHGuucGC4xPvBVtIfCAqJjkjnmBEGJYNXB8sBc2EDt7xDgGenw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.6': + resolution: {integrity: sha512-9G5AtEkpN/A89BILHHEL9aYRYTuFjwa8PUQqoEg8SDrwOZBKpwYqNlQCBNI5QPwGrf7qFlb6LNFilo8FCSA3Wg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.6': + resolution: {integrity: sha512-Ezx2E5D6Siz805j41x91JiGbzuCm8i2U98U37SQ7ytY737+wsJq+P9nPL6UmqDww491zO7MTivYOTrtmTTeCSw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.6': + resolution: {integrity: sha512-8EZ1Q3PB7yUjvR4MbFAnCBF1wF0XRe5QQqbtlJXP7ZX5C8iYlSQJugUhj9oS0gbUP4jp9qGhaIgFnV9Nvod86Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.6': + resolution: {integrity: sha512-3JI27TALItfZ/qaxXKdbRXfV6WepUoUa3IN07FE+1xxoRdl3JIyrp5PiDF0vOHsPgEiOFkF8s3B3BRymQkdwGA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.6': + resolution: {integrity: sha512-BNOGeNFNRZDT7Hfc7b9JbTZFXkpAXeXl/CZWtxdd5CNfBaor/0RkJjg+q+E2gwVIvGqqp/NclThKFGrhuScVhw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.6': + resolution: {integrity: sha512-D3eEyLSIim5WF5fHbwnx+ryk0delN7fJyBrDTO3wVCCNBZ/cA8aCGwLS3SiuK+vRI4E7dqikAlStGuR20dBMuw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.6': + resolution: {integrity: sha512-xwbs9g8zLbHzj1poG1uh27xtubsu27Gk2nuN6kvvGDybjiTfwMksQAnF+phlvI5oeXkJWWDc6kg1t3XpgUYvrg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.6': + resolution: {integrity: sha512-Tr6rhRNnFthvXr45zWVZQ5P8jZ5UwZnXNc7Qnu80cP/VVYtSioOPmvv7wP6UMgRfkBCJr2Pm60ua0YBDSJjgnw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.6': + resolution: {integrity: sha512-0RAxO1pS/6lY1mSoD2Apm+6aPyfUlm+OWKgncIdj/eVN9I+pDY3lKgD7xsUNFei1u7MZQETGOo6m8POXMDp53A==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/mathjax@0.0.40': + resolution: {integrity: sha512-rHusx08LCg92WJxrsM3SPjvLTSvK5C+gealtSuhKbEOcUZfWlwigaFoPLf6Dfxhg4oryN5qP9Sj7zOQ4HYXINw==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dompurify@3.4.15: + resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} + + elkjs@0.12.0: + resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.1: + resolution: {integrity: sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} + hasBin: true + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + mathjax-full@3.2.2: + resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.3: + resolution: {integrity: sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mhchemparser@4.2.1: + resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mj-context-menu@0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + + monaco-editor@0.57.0: + resolution: {integrity: sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + oxc-transform-react@0.152.0: + resolution: {integrity: sha512-CAx+SKDU4uSOmQnyn7daMj/v/h8tF3fnaUMi4yImKAARPnXV0BieHe/qw1LTcK4KlnIuAhpfUJfSfNKIBh8HqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-resizable-panels@4.14.1: + resolution: {integrity: sha512-OB1bXDNTLcGgTTbaX6Dn5efZhlMboSnkfr1w4xpsJTHPEbRJpACBhXpgRCXozgPO1ztgFeZRUe5J3mFNCVKm5g==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + rehype-mathjax@7.1.0: + resolution: {integrity: sha512-mJHNpoqCC5UZ24OKx0wNjlzV18qeJz/Q/LtEjxXzt8vqrZ1Z3GxQnVrHcF5/PogcXUK8cWwJ4U/LWOQWEiABHw==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.63.6: + resolution: {integrity: sha512-w4+GKyTBqshj84F1GF3s2zxAPFZPEmNkCbQtqilsYS7GsksmX6VoV7UggfZg8sUHCCOkBe7UtQp6fZwbH311FQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speech-rule-engine@4.1.4: + resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} + hasBin: true + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + wicked-good-xpath@1.3.0: + resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@fontsource-variable/inter@5.3.0': {} + + '@fontsource-variable/jetbrains-mono@5.3.0': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdx': 2.0.14 + acorn: 8.18.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.1 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.18.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@mdx-js/rollup@3.1.1(rollup@4.63.6)': + dependencies: + '@mdx-js/mdx': 3.1.1 + '@rollup/pluginutils': 5.4.0(rollup@4.63.6) + rollup: 4.63.6 + source-map: 0.7.6 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@oxc-project/types@0.152.0': {} + + '@oxc-transform-react/binding-android-arm-eabi@0.152.0': + optional: true + + '@oxc-transform-react/binding-android-arm64@0.152.0': + optional: true + + '@oxc-transform-react/binding-darwin-arm64@0.152.0': + optional: true + + '@oxc-transform-react/binding-darwin-x64@0.152.0': + optional: true + + '@oxc-transform-react/binding-freebsd-x64@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm-gnueabihf@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm-musleabihf@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-arm64-musl@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-ppc64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-riscv64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-riscv64-musl@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-s390x-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-x64-gnu@0.152.0': + optional: true + + '@oxc-transform-react/binding-linux-x64-musl@0.152.0': + optional: true + + '@oxc-transform-react/binding-openharmony-arm64@0.152.0': + optional: true + + '@oxc-transform-react/binding-win32-arm64-msvc@0.152.0': + optional: true + + '@oxc-transform-react/binding-win32-ia32-msvc@0.152.0': + optional: true + + '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/pluginutils@5.4.0(rollup@4.63.6)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.7 + optionalDependencies: + rollup: 4.63.6 + + '@rollup/rollup-android-arm-eabi@4.63.6': + optional: true + + '@rollup/rollup-android-arm64@4.63.6': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.6': + optional: true + + '@rollup/rollup-darwin-x64@4.63.6': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.6': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.6': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.6': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.6': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.6': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.6': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.6': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.6': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.6': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.6': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.6': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.6': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.6': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/katex@0.16.8': {} + + '@types/mathjax@0.0.40': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + + '@types/ms@2.1.0': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@ungap/structured-clone@1.4.0': {} + + '@vitejs/plugin-react@6.1.1(oxc-transform-react@0.152.0)(vite@8.3.2(@types/node@24.19.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.2(@types/node@24.19.1) + optionalDependencies: + oxc-transform-react: 0.152.0 + + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.2(@types/node@24.19.1) + + '@vitest/spy@5.0.3': {} + + '@xmldom/xmldom@0.9.10': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + astring@1.9.0: {} + + bail@2.0.2: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + ccount@2.0.1: {} + + chai@6.3.0: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + collapse-white-space@2.1.0: {} + + comma-separated-tokens@2.0.3: {} + + commander@13.1.0: {} + + commander@8.3.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dompurify@3.4.15: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + elkjs@0.12.0: {} + + es-module-lexer@2.3.2: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.18.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esm@3.2.25: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + extend@3.0.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + js-yaml@5.4.2: + dependencies: + argparse: 2.0.1 + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + longest-streak@3.1.0: {} + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + markdown-extensions@2.0.0: {} + + marked@14.0.0: {} + + mathjax-full@3.2.2: + dependencies: + esm: 3.2.25 + mhchemparser: 4.2.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 4.1.4 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mhchemparser@4.2.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.47 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mj-context-menu@0.6.1: {} + + monaco-editor@0.57.0: + dependencies: + dompurify: 3.4.15 + marked: 14.0.0 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + obug@2.2.1: {} + + oxc-transform-react@0.152.0: + optionalDependencies: + '@oxc-transform-react/binding-android-arm-eabi': 0.152.0 + '@oxc-transform-react/binding-android-arm64': 0.152.0 + '@oxc-transform-react/binding-darwin-arm64': 0.152.0 + '@oxc-transform-react/binding-darwin-x64': 0.152.0 + '@oxc-transform-react/binding-freebsd-x64': 0.152.0 + '@oxc-transform-react/binding-linux-arm-gnueabihf': 0.152.0 + '@oxc-transform-react/binding-linux-arm-musleabihf': 0.152.0 + '@oxc-transform-react/binding-linux-arm64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-arm64-musl': 0.152.0 + '@oxc-transform-react/binding-linux-ppc64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-riscv64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-riscv64-musl': 0.152.0 + '@oxc-transform-react/binding-linux-s390x-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-x64-gnu': 0.152.0 + '@oxc-transform-react/binding-linux-x64-musl': 0.152.0 + '@oxc-transform-react/binding-openharmony-arm64': 0.152.0 + '@oxc-transform-react/binding-win32-arm64-msvc': 0.152.0 + '@oxc-transform-react/binding-win32-ia32-msvc': 0.152.0 + '@oxc-transform-react/binding-win32-x64-msvc': 0.152.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + property-information@7.2.0: {} + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-resizable-panels@4.14.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + react@19.3.0: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + rehype-mathjax@7.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/mathjax': 0.0.40 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + mathjax-full: 3.2.2 + unified: 11.0.5 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + rollup@4.63.6: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.6 + '@rollup/rollup-android-arm64': 4.63.6 + '@rollup/rollup-darwin-arm64': 4.63.6 + '@rollup/rollup-darwin-x64': 4.63.6 + '@rollup/rollup-freebsd-arm64': 4.63.6 + '@rollup/rollup-freebsd-x64': 4.63.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.6 + '@rollup/rollup-linux-arm-musleabihf': 4.63.6 + '@rollup/rollup-linux-arm64-gnu': 4.63.6 + '@rollup/rollup-linux-arm64-musl': 4.63.6 + '@rollup/rollup-linux-loong64-gnu': 4.63.6 + '@rollup/rollup-linux-loong64-musl': 4.63.6 + '@rollup/rollup-linux-ppc64-gnu': 4.63.6 + '@rollup/rollup-linux-ppc64-musl': 4.63.6 + '@rollup/rollup-linux-riscv64-gnu': 4.63.6 + '@rollup/rollup-linux-riscv64-musl': 4.63.6 + '@rollup/rollup-linux-s390x-gnu': 4.63.6 + '@rollup/rollup-linux-x64-gnu': 4.63.6 + '@rollup/rollup-linux-x64-musl': 4.63.6 + '@rollup/rollup-openbsd-x64': 4.63.6 + '@rollup/rollup-openharmony-arm64': 4.63.6 + '@rollup/rollup-win32-arm64-msvc': 4.63.6 + '@rollup/rollup-win32-ia32-msvc': 4.63.6 + '@rollup/rollup-win32-x64-gnu': 4.63.6 + '@rollup/rollup-win32-x64-msvc': 4.63.6 + fsevents: 2.3.3 + + scheduler@0.28.0: {} + + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + speech-rule-engine@4.1.4: + dependencies: + '@xmldom/xmldom': 0.9.10 + commander: 13.1.0 + wicked-good-xpath: 1.3.0 + + std-env@4.3.0: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.24.6: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-plugin-singlefile@2.3.3(rollup@4.63.6)(vite@8.3.2(@types/node@24.19.1)): + dependencies: + micromatch: 4.0.8 + vite: 8.3.2(@types/node@24.19.1) + optionalDependencies: + rollup: 4.63.6 + + vite@8.3.2(@types/node@24.19.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.1 + fsevents: 2.3.3 + + vitest@5.0.3(@types/node@24.19.1)(vite@8.3.2(@types/node@24.19.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.2(@types/node@24.19.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.19.1 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.1: {} + + wicked-good-xpath@1.3.0: {} + + zod@4.6.5: {} + + zwitch@2.0.4: {} diff --git a/pocs/zeroth-playground/tsconfig.json b/pocs/zeroth-playground/tsconfig.json new file mode 100644 index 00000000..fde5a78d --- /dev/null +++ b/pocs/zeroth-playground/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "types": ["vite/client", "mdx"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["compiler", "examples", "playground", "vite.config.ts"] +} diff --git a/pocs/zeroth-playground/vite.config.ts b/pocs/zeroth-playground/vite.config.ts new file mode 100644 index 00000000..6aff18cf --- /dev/null +++ b/pocs/zeroth-playground/vite.config.ts @@ -0,0 +1,58 @@ +/// +import { fileURLToPath } from "node:url"; + +import mdx from "@mdx-js/rollup"; +import react from "@vitejs/plugin-react"; +import rehypeMathjax from "rehype-mathjax/svg"; +import remarkMath from "remark-math"; +import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const fromRoot = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +export default defineConfig({ + // index.html lives in playground/; compiler/ and examples/ are imported from there. + root: fromRoot("./playground"), + build: { + // The one-file build stays at dist/index.html, beside package.json. + outDir: fromRoot("./dist"), + emptyOutDir: true, + }, + plugins: [ + // Math in the pages is rendered to inline SVG at build time: no fonts or stylesheet to ship. + // Only .mdx files are pages, so a .md file imported with ?raw stays text. + { + enforce: "pre", + ...mdx({ include: /\.mdx$/u, remarkPlugins: [remarkMath], rehypePlugins: [rehypeMathjax] }), + }, + // The React Compiler, in its Rust port, memoizes components and hooks, so the code derives + // values in render and keeps effects for the systems React does not own. + react({ + compiler: { + // Every diagnostic fails the build, bailouts included, so no component is left + // unmemoized without anyone noticing. + panicThreshold: "all_errors", + // oxc-transform-react leaves recoverable diagnostics out unless asked. + reportDiagnostics: true, + logDiagnostics: true, + // Compile a "use no memo" function too. A diagnostic in one is logged, not fatal. + ignoreUseNoForget: true, + // Stricter checks: no Date.now() or Math.random() in render, no effect that only + // derives state, and a manual memo that lists all its dependencies and returns a value. + environment: { + validateNoImpureFunctionsInRender: true, + validateNoDerivedComputationsInEffects: true, + validateExhaustiveMemoizationDependencies: true, + validateNoVoidUseMemo: true, + }, + }, + }), + // One HTML file: JS, CSS, fonts and the editor worker inlined, so it opens from disk. + viteSingleFile({ removeViteModuleLoader: true }), + ], + test: { + // Vitest's root defaults to Vite's root, playground/: widen it to the project. + root: fromRoot("."), + include: ["{compiler,examples,playground}/**/*.test.{ts,tsx}"], + }, +}); From 2772c13c368a53ef5ecaca32b142235ae42cf980 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 2 Oct 2026 10:50:38 +0200 Subject: [PATCH 02/20] List eight examples that each add one idea, from one step to clock races The examples climb two rungs. Steps: one step as a module, who gets the token, a capped place, and weighted, read and inhibitor arcs. Rates: rates as coins, rates as clocks, two inputs under clocks, and conflicts under clocks. Bucket stays unlisted, to show what a net with code strings looks like when no code parser is given. Fork merges into the conflict example, the coins page moves to the birth-death net, a new arcs net takes over the arc lessons, and the queue, SIR, boiler and drones examples go. Cycle opens in the monolithic shape, so the one-class step comes first. Each open question lives on one page, and examples/README.md lists the ladder with what each example teaches. --- pocs/zeroth-playground/README.md | 6 +- pocs/zeroth-playground/compiler/README.md | 6 +- pocs/zeroth-playground/compiler/mapping.md | 8 +- pocs/zeroth-playground/examples/README.md | 36 +++-- .../examples/__snapshots__/arcs/net.py | 73 ++++++++++ .../__snapshots__/birth-death-clocked/net.py | 64 +++++++++ .../examples/__snapshots__/birth-death/net.py | 4 +- .../examples/__snapshots__/cafe-queue/net.py | 130 ++++++++++-------- .../{fork-clocked => conflict-clocked}/net.py | 0 .../examples/__snapshots__/conflict/net.py | 56 +++++--- .../examples/__snapshots__/cycle/net.py | 69 +++------- .../examples/__snapshots__/fork/net.py | 98 ------------- .../examples/__snapshots__/queue/net.py | 65 --------- .../examples/__snapshots__/sir-model/net.py | 86 ------------ .../examples/{fork => arcs}/meta.ts | 6 +- .../examples/arcs/net.pn.yaml | 24 ++++ pocs/zeroth-playground/examples/arcs/page.mdx | 25 ++++ .../components/coin-clock-explorer.css | 0 .../components/coin-clock-explorer.tsx | 2 +- .../coin-clock-explorer/exponential.test.ts | 0 .../coin-clock-explorer/exponential.ts | 0 .../components/coin-clock-explorer/format.ts | 0 .../modifier-class.test.ts | 0 .../coin-clock-explorer/modifier-class.ts | 0 .../coin-clock-explorer/plot-frame.test.ts | 0 .../coin-clock-explorer/plot-frame.ts | 0 .../coin-clock-explorer/range-field.tsx | 0 .../coin-clock-explorer/survival-plot.tsx | 0 .../coin-clock-explorer/use-width.ts | 0 .../examples/birth-death-clocked/meta.ts | 11 ++ .../examples/birth-death-clocked/net.pn.yaml | 16 +++ .../examples/birth-death-clocked/page.mdx | 46 +++++++ .../components/coin-draws.css | 2 +- .../components/coin-draws.tsx | 10 +- .../examples/birth-death/meta.ts | 8 +- .../examples/birth-death/net.pn.yaml | 2 +- .../examples/birth-death/page.mdx | 51 ++----- .../zeroth-playground/examples/boiler/meta.ts | 11 -- .../examples/boiler/net.pn.yaml | 33 ----- .../examples/boiler/page.mdx | 29 ---- .../zeroth-playground/examples/bucket/meta.ts | 6 +- .../examples/bucket/page.mdx | 10 +- .../examples/cafe-queue/meta.ts | 6 +- .../examples/cafe-queue/page.mdx | 19 +-- .../examples/capacity/meta.ts | 6 +- .../examples/capacity/page.mdx | 8 +- pocs/zeroth-playground/examples/catalog.ts | 17 ++- .../components/clock-race.css | 0 .../components/clock-race.tsx | 0 .../meta.ts | 4 +- .../net.pn.yaml | 2 +- .../page.mdx | 2 +- .../examples/conflict/meta.ts | 8 +- .../examples/conflict/net.pn.yaml | 6 +- .../examples/conflict/page.mdx | 27 ++-- pocs/zeroth-playground/examples/cycle/meta.ts | 4 +- .../zeroth-playground/examples/cycle/page.mdx | 15 +- .../zeroth-playground/examples/drones/meta.ts | 11 -- .../examples/drones/net.pn.yaml | 53 ------- .../examples/drones/page.mdx | 19 --- .../examples/examples.test.ts | 13 +- .../examples/fork/net.pn.yaml | 27 ---- pocs/zeroth-playground/examples/fork/page.mdx | 10 -- pocs/zeroth-playground/examples/queue/meta.ts | 11 -- .../examples/queue/net.pn.yaml | 19 --- .../zeroth-playground/examples/queue/page.mdx | 21 --- .../examples/sir-model/meta.ts | 11 -- .../examples/sir-model/net.pn.yaml | 28 ---- .../examples/sir-model/page.mdx | 31 ----- pocs/zeroth-playground/playground/README.md | 2 +- .../playground/app/document.test.ts | 40 +++--- .../playground/app/route.test.ts | 8 +- .../zeroth-playground/playground/app/route.ts | 4 +- .../compiler-view/compiler-guide.mdx | 2 +- .../compiler-view/stage-sample.test.ts | 104 +++++++------- .../playground/compiler-view/stages.ts | 6 +- .../playground/options/option-edits.test.ts | 12 +- .../playground/preview/net-graph.test.ts | 59 ++++---- 78 files changed, 647 insertions(+), 961 deletions(-) create mode 100644 pocs/zeroth-playground/examples/__snapshots__/arcs/net.py create mode 100644 pocs/zeroth-playground/examples/__snapshots__/birth-death-clocked/net.py rename pocs/zeroth-playground/examples/__snapshots__/{fork-clocked => conflict-clocked}/net.py (100%) delete mode 100644 pocs/zeroth-playground/examples/__snapshots__/fork/net.py delete mode 100644 pocs/zeroth-playground/examples/__snapshots__/queue/net.py delete mode 100644 pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py rename pocs/zeroth-playground/examples/{fork => arcs}/meta.ts (51%) create mode 100644 pocs/zeroth-playground/examples/arcs/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/arcs/page.mdx rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer.css (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer.tsx (98%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/exponential.test.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/exponential.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/format.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/modifier-class.test.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/modifier-class.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/plot-frame.test.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/plot-frame.ts (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/range-field.tsx (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/survival-plot.tsx (100%) rename pocs/zeroth-playground/examples/{birth-death => birth-death-clocked}/components/coin-clock-explorer/use-width.ts (100%) create mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/meta.ts create mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml create mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/page.mdx rename pocs/zeroth-playground/examples/{queue => birth-death}/components/coin-draws.css (95%) rename pocs/zeroth-playground/examples/{queue => birth-death}/components/coin-draws.tsx (88%) delete mode 100644 pocs/zeroth-playground/examples/boiler/meta.ts delete mode 100644 pocs/zeroth-playground/examples/boiler/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/boiler/page.mdx rename pocs/zeroth-playground/examples/{fork-clocked => conflict-clocked}/components/clock-race.css (100%) rename pocs/zeroth-playground/examples/{fork-clocked => conflict-clocked}/components/clock-race.tsx (100%) rename pocs/zeroth-playground/examples/{fork-clocked => conflict-clocked}/meta.ts (86%) rename pocs/zeroth-playground/examples/{fork-clocked => conflict-clocked}/net.pn.yaml (95%) rename pocs/zeroth-playground/examples/{fork-clocked => conflict-clocked}/page.mdx (97%) delete mode 100644 pocs/zeroth-playground/examples/drones/meta.ts delete mode 100644 pocs/zeroth-playground/examples/drones/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/drones/page.mdx delete mode 100644 pocs/zeroth-playground/examples/fork/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/fork/page.mdx delete mode 100644 pocs/zeroth-playground/examples/queue/meta.ts delete mode 100644 pocs/zeroth-playground/examples/queue/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/queue/page.mdx delete mode 100644 pocs/zeroth-playground/examples/sir-model/meta.ts delete mode 100644 pocs/zeroth-playground/examples/sir-model/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/sir-model/page.mdx diff --git a/pocs/zeroth-playground/README.md b/pocs/zeroth-playground/README.md index 0f6a30b9..b3cb55ff 100644 --- a/pocs/zeroth-playground/README.md +++ b/pocs/zeroth-playground/README.md @@ -22,10 +22,10 @@ pnpm dev - **Views.** The IR editor has highlighting, completion of the IR's keys and places, and a hover that reads the IR trace. The module view lists the Python files and the diagnostics, each at the IR line of its item. The preview draws the net as an SVG laid out with elkjs. - **Options beside the IR.** The Compiler options panel under the IR editor holds the compiler options; the IR carries none. An option that does not apply to the net is greyed, with the reason. Each example opens with its own options, and Reset restores them with the text. - **Cross-highlighting.** Hovering a line in either editor lights the lines on the other side that name the same net item, and the item in the preview. Hovering a place or a transition in the preview lights its lines on both sides. -- **The example ladder.** The examples climb from plain nets (Steps) to stochastic ones (Rates), each named by the feature it tackles. The picker in the header lists them. Each has a page; some end with open questions, each naming who can settle it: HASH, Zeroth or both. Two pages carry a CSS animation, and the Birth–death page has an explorer with a Δt slider, a draw and the coin's rate. -- **The Compiler view.** A switch in the header opens a guide, a graph of the pipeline's stages and a card per stage with a live sample from the open example. `#compiler`, or `#compiler/queue`, opens it. +- **The example ladder.** The examples climb from plain nets (Steps) to stochastic ones (Rates), each adding one idea and named by it. The picker in the header lists them. Each has a page; most end with open questions, each naming who can settle it: HASH, Zeroth or both. Two pages carry a CSS animation, and *Rates as clocks* has an explorer with a Δt slider, a draw and the coin's rate. +- **The Compiler view.** A switch in the header opens a guide, a graph of the pipeline's stages and a card per stage with a live sample from the open example. `#compiler`, or `#compiler/capacity`, opens it. -The coloured examples (boiler, drones, bucket) carry code strings and are refused with `code-not-parsed`: reading the code back needs a parser built on the TypeScript compiler, far past the single-file budget. The picker leaves them out; `#boiler`, `#drones` and `#bucket` open them, with the refusal and the page. +The coloured example, Bucket, carries a code string and is refused with `code-not-parsed`: reading the code back needs a parser built on the TypeScript compiler, far past the single-file budget. The picker leaves it out; `#bucket` opens it, with the refusal and the page. ## Working on it diff --git a/pocs/zeroth-playground/compiler/README.md b/pocs/zeroth-playground/compiler/README.md index 8fb7c0fa..f6be5509 100644 --- a/pocs/zeroth-playground/compiler/README.md +++ b/pocs/zeroth-playground/compiler/README.md @@ -335,7 +335,7 @@ For every example it compiles the IR through `index.ts` under the options the ex It prints one row per example and option set: `constructs`, `raises`, or `refused` with the compiler's codes. The check passes no code parser, so a net with code strings is refused with `code-not-parsed`. Each raise follows, with the error, the generated lines it passed through and the folder that keeps the files. It exits with 1 when anything raises, and with 2 when `ZRTH_PATH` is unset or zrth does not import from it. -It is not one of the gates, since CI has no zrth. Run it after a change to the output, and when zrth's `spn` branch moves. At 5bd3bf9 every example the compiler accepts constructs under every set: 38 construct, 9 are refused. Node runs the compiler's TypeScript through `scripts/resolve-ts.ts`, which resolves its imports written without an extension. +It is not one of the gates, since CI has no zrth. Run it after a change to the output, and when zrth's `spn` branch moves. At 5bd3bf9 every set constructs but Bucket's two, which the compiler refuses: `code-not-parsed` by default, `modular-coloured-not-lowered` under the modular shape. Node runs the compiler's TypeScript through `scripts/resolve-ts.ts`, which resolves its imports written without an extension. ## Extending it @@ -377,6 +377,6 @@ The TypeScript in this folder is formatted as Prettier formats it at a width of [mapping.md](mapping.md) tracks these with the other open questions. -- The playground passes no `parseCode`, so the nets with code strings (Boiler, Drones, Bucket) are refused with `code-not-parsed`. A TypeScript parser does not fit the playground's one-file budget. -- Clock rates refuse a capacity (`clocks-capacity`) and an arc weight above one (`clocks-arc-weight`): SPN tests a count against zero only and moves one token at a time. So Queue and the SIR model have no clocked output. Sections (b) and (c) of [mapping.md](mapping.md) hold the questions. +- The playground passes no `parseCode`, so a net with code strings, such as the Bucket example, is refused with `code-not-parsed`. A TypeScript parser does not fit the playground's one-file budget. +- Clock rates refuse a capacity (`clocks-capacity`) and an arc weight above one (`clocks-arc-weight`): SPN tests a count against zero only and moves one token at a time. So a stochastic net with either has no clocked output. Sections (b) and (c) of [mapping.md](mapping.md) hold the questions. - `pnpm check:zrth` checks that the output constructs, not that it runs. `zrth.simulate` takes no external but `t`, so the clocked output under `conflicts: nondet`, whose picks are externals, cannot run there. No run of the clocked output under `conflicts: sweep` is kept in the repository. diff --git a/pocs/zeroth-playground/compiler/mapping.md b/pocs/zeroth-playground/compiler/mapping.md index 41963298..939b217f 100644 --- a/pocs/zeroth-playground/compiler/mapping.md +++ b/pocs/zeroth-playground/compiler/mapping.md @@ -32,7 +32,7 @@ Two rate strategies recur below. Coins test each rated transition once per step **Files.** `layout: per-module` writes `net.py` (variables, imports, `compose`) plus one file per class, `transition_birth.py` for `Transition_Birth`. Every file opens on its imports: the compiler writes no header naming the net or where it came from. Every module's step method is `next`; there is no option for `update`. At the spn tip `next` and `flow` alias `update` and `delay` for every theory (`_method` in `sugar.py`, `test_next_and_flow_are_aliases_for_update_and_delay`), and `origin/main` accepts `update` only. -The cycle as IR (`examples/cycle/net.pn.yaml`) and as the monolithic step (golden "lowers a plain net to an LIA module with one step of the net per next", `lower/monolithic.test.ts`): +The cycle as IR (`examples/cycle/net.pn.yaml`) and as the monolithic step (golden "lowers a plain net to an LIA module with one step of the net per next", `lower/monolithic.test.ts`, and `examples/__snapshots__/cycle/net.py`): ```yaml transitions: @@ -56,7 +56,7 @@ transitions: return A, B ``` -The modular composition (`examples/__snapshots__/cycle/net.py`) and the clocked one (`birthDeathPython`, `testing/birth-death.fixtures.ts`): +The cycle's modular composition (golden "composes one module per transition and per place in the modular shape", `lower/modular.test.ts`, which pins the `compose` line) and the clocked one (`birthDeathPython`, `testing/birth-death.fixtures.ts`): ```python transition_Go = Transition_Go(theory=LIA, ctrl=(fire_Go,), extl=(A,)) @@ -131,6 +131,7 @@ Pool_0_x = Var(REAL) Open questions: - Capacities under SPN: an ordering on `Nat`, or a complement place? (Zeroth) +- Should a capacity test count the room a later transition frees in the same step? Today it counts only the transitions before it in record order. (HASH) - Coloured tokens under SPN: a `Real` sort, or an LRA module beside SPN ones? (Zeroth) ### (c) Transitions, enabling @@ -237,7 +238,7 @@ Open questions: `simulate.py` takes no external but `t` (see (i)), so the clocked nondet output does not run there. Under a driver, a false pick at expiry holds the flow horizon (`hi` in `simulate.py`) at 0. A round that then changes nothing raises `NoFlow` (`stuck at {now}: time cannot pass and the round changes nothing`). Two alternatives: pause the clock in `flow` on the pick, or re-arm it at expiry whatever the pick. `GuardSkipped` names a `next` that would change state before `hi` and not at `hi`. Every `fires_T` the compiler writes needs `clk_T == 0`, which holds only where `clk_T >= 0` stops time, so its output cannot raise it. -Coins (golden "has each transition in a conflict wait for an undriven pick under conflicts nondet", `lower/monolithic.test.ts`) and clocks (`examples/__snapshots__/fork-clocked/net.py`): +Coins (golden "has each transition in a conflict wait for an undriven pick under conflicts nondet", `lower/monolithic.test.ts`) and clocks (`examples/__snapshots__/conflict-clocked/net.py`): ```python fire_TakeLeft = (Pool >= 2) & X(pick_TakeLeft) # TakeLeft: 2 Pool -> Left @@ -259,6 +260,7 @@ Open questions: - Ties: two consumers of one place expiring together produce without consuming (`nextCount` in `lower/clocks.ts`); Zeroth's `test_birth_death_over_a_long_run` covers only a producer and a consumer. Under exponential clocks equal remainders have chance zero: `_breakpoint` in `simulate.py` pins only the expiring clock. Intended? (HASH, Zeroth) - Should a coin step allow ties? Two transitions can both fire in one step, and both apply, where clocks fire one at a time. Where one firing disables another, as through an inhibitor arc, the two differ. (HASH, Zeroth) - Should read and inhibitor arcs count as conflicts? Today they do, so two readers of one place get picks that hold them without competing. (HASH) +- Are `control: open` and `conflicts: nondet` one mechanism? Both add an undriven Bool to a guard, `X(go_T)` or `X(pick_T)`, so one option for external choices could replace both. (HASH) - How picks are driven under SPN. (Zeroth) ### (f) Kernels and outputs diff --git a/pocs/zeroth-playground/examples/README.md b/pocs/zeroth-playground/examples/README.md index 3338c73e..7427654e 100644 --- a/pocs/zeroth-playground/examples/README.md +++ b/pocs/zeroth-playground/examples/README.md @@ -4,20 +4,32 @@ One folder per Petri net: its IR document, the compiler options it opens with, a ## The ladder -The examples climb a ladder of rungs, from the plainest net up. `LADDER` in `catalog.ts` defines the rungs: +The examples climb a ladder of rungs, from the plainest net up. Each one adds one idea to the ones before it, and is named by that idea, then by the net it uses: "Rates as coins", using Birth–death. `LADDER` in `catalog.ts` lists the rungs: | Rung | Holds | | --- | --- | | `steps` | plain nets: how one step of the net becomes modules | | `rates` | stochastic nets: coins tested each step, or clocks in continuous time | -| `colours` | coloured tokens, dynamics and kernels; refused in the playground, which has no code parser, and unlisted | - -Each example tackles one feature and is named by it, then by the net it uses: "Rates as coins", using Queue. The picker lists the listed examples rung by rung, sorted by `order`, and skips a rung with none. Put a new example where its feature builds on the ones before it. +| `colours` | coloured tokens; refused in the playground, which has no code parser, and unlisted | + +| Order | Example | Net | Opens with | What you learn | +| --- | --- | --- | --- | --- | +| 10 | One step as a module | Cycle | monolithic | A step fires every enabled transition once, in record order: it takes at once and lands at the end. The step is one `next` over an Int per place, or one module per transition and per place, read through `X(...)`. | +| 20 | Who gets the token | Conflict | modular, `conflicts: nondet` | Record order settles a shared input place through `avail_Pool`. Nondet adds a `pick_T` per contender, so every resolution is a run. A transition in no conflict gets no pick. | +| 30 | A capped place | Capacity | modular | `fill_Buffer` lets a later producer count the token an earlier one adds in the same step. Clocks refuse capacities. | +| 40 | Weighted, read and inhibitor arcs | Arcs | modular | A weight w tests `P >= w` and takes w, a read arc tests without taking, an inhibitor arc tests `P < w`. | +| 50 | Rates as coins | Birth–death | modular, `dt: 0.5` | A rate becomes an input draw `u_T` tested against e^(−λ·Δt), computed at compile time. Places become Real in LRA, or stay Int with a `Draw_T` module per transition. | +| 60 | Rates as clocks | Birth–death | `rates: clock` | A rate becomes a clock `clk_T` armed with `exp(rate)` and an event `ev_T`, places become Nat counters, and `compose` hides the clocks. A coin's rate stays below λ. | +| 70 | Two inputs under clocks | Café queue | `rates: clock` | A transition with two input places keeps both tests and pauses its clock while either is empty, where Zeroth's model lets the places decide. | +| 80 | Conflicts under clocks | Conflict with rates | `rates: clock`, `conflicts: nondet` | A conflict becomes a race between clocks. A pick is read at expiry, and a false pick stops time. | +| 90 | Code strings are refused, unlisted | Bucket | monolithic | A rate written as code is refused with `code-not-parsed` until a code parser is passed. | + +The picker lists the listed examples rung by rung, sorted by `order`, and skips a rung with none. Put a new example where its idea builds on the ones before it, and only when no example already teaches that idea. ## One folder per example ``` -queue/ +birth-death/ meta.ts what the picker and the page header show, the rung, the order, the options net.pn.yaml the IR document page.mdx the documentation page @@ -25,7 +37,7 @@ queue/ components/coin-draws.css its styles, imported by the component ``` -The folder name is the id: the picker's key, the page's key and the URL hash, `#queue`. `catalog.ts` collects the folders with `import.meta.glob`, so nothing else registers an example. +The folder name is the id: the picker's key, the page's key and the URL hash, `#birth-death`. `catalog.ts` collects the folders with `import.meta.glob`, so nothing else registers an example. `meta.ts` default-exports an `ExampleMeta`, defined in `catalog.ts`: @@ -43,15 +55,15 @@ The folder name is the id: the picker's key, the page's key and the URL hash, `# The IR carries no options. An example's options sit in `meta.ts`, as a `CompilerOptions` object, and the playground's options panel edits them from there. Reset restores them. -- An example opens in the modular shape wherever that shape compiles its net: `{ shape: "modular" }`. Coloured nets and nets with dynamics stay monolithic, the default. -- Add another option only where the page discusses it, such as `dt: 0.5` on Queue, or clock rates and `conflicts: "nondet"` on Fork under clocks. +- An example opens with the options that show its idea, and the page discusses each one. Cycle opens in the default monolithic shape, so the one-class step comes first. The other plain and coin examples open modular, `{ shape: "modular" }`, and the clock examples under `rates: "clock"`. Bucket, which no shape compiles here, opens with none. +- Add another option only where the page discusses it, such as `dt: 0.5` on *Rates as coins*, or `conflicts: "nondet"` on *Who gets the token*. - Every option an example opens with applies to its net, so the panel shows it and `compile` raises no `option-not-applicable` warning. `examples.test.ts` checks it. [compiler/README.md](../compiler/README.md#options) lists the options and the nets each applies to. ## Adding an example -1. Copy a folder from the same rung and rename it to the new id. +1. Check that no example already teaches the idea. Copy a folder from the same rung and rename it to the new id. 2. Edit `meta.ts`. Pick an `order` between the examples it builds on and the ones that build on it. 3. Replace `net.pn.yaml`. Write it in the block format the other examples use, one key per line and code as `|` blocks. 4. Rewrite `page.mdx`. Put a figure it alone uses in its `components/` folder. @@ -68,7 +80,7 @@ The problem the example shows, in the first two sentences. Then what the compiler writes for it, quoting the few Python lines that matter: ```python -return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) +return X(u_Birth) >= 0.36787944117144233 ```
    @@ -88,8 +100,8 @@ return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) - The documentation view writes the net's name and the heading from `meta.ts`; the page starts with its first paragraph. - The documentation view passes `Figure` and `OpenQuestion` to every page as MDX components, from `playground/docs/page-components.ts`. A page uses them without importing them, and imports only its own `./components/`. It also renders fenced code through `CodeBlock`, which wraps a long line under a hanging indent, so quote a line of Python in a fenced block when it is longer than about 25 characters: inline code does not wrap. -- Open with the problem, not with the net or the options. Name the IR items and the Python names the reader will see. Say once, on the page that needs it, what a later page builds on. -- **Options** lists only the options that change this net's output, never one the panel greys out, and not the shape the example opens with. +- Open with the problem, not with the net or the options. Name the IR items and the Python names the reader will see. Say once, on the page that needs it, what a later page builds on. Name another example by its feature, in italics: *Rates as clocks*. +- **Options** lists only the options that change this net's output, never one the panel greys out, and not the shape the example opens with. Leave the section out when the body already covers them. - Only the first page, Cycle, has a **Hover** section: one bullet on what pointing at a line or a node does. - An open question is about the semantics of the compilation: what a net means as modules, or what Zeroth can express. Tooling, executors, numerics and plans belong elsewhere. Each question sits on the one page where it matters most. - `owner` is `"HASH"`, `"Zeroth"` or `"HASH and Zeroth"`: who can settle the question. Keep it in step with the owner in [compiler/mapping.md](../compiler/mapping.md) where the question is listed there. diff --git a/pocs/zeroth-playground/examples/__snapshots__/arcs/net.py b/pocs/zeroth-playground/examples/__snapshots__/arcs/net.py new file mode 100644 index 00000000..d492eb57 --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/arcs/net.py @@ -0,0 +1,73 @@ +from zrth import LIA, Bool, Int, Var +from zrth import Module as compose +from zrth.sugar import Module, X, ite + +INT = Int([1, 1]) +BOOL = Bool([1, 1]) + +Parts = Var(INT) +Power = Var(INT) +Jam = Var(INT) +Products = Var(INT) + +fire_Join = Var(BOOL) # Join fires this step + + +class Transition_Join(Module): + """Join: 2 Parts, read Power, inhibitor Jam -> Products""" + + def init(self, Parts, Power, Jam): + return False + + def next(self, fire_Join, Parts, Power, Jam): + return (Parts >= 2) & (Power >= 1) & (Jam < 1) + + +class Place_Parts(Module): + """Parts: taken by Join""" + + def init(self, fire_Join): + return 5 + + def next(self, Parts, fire_Join): + Parts = ite(X(fire_Join), Parts - 2, Parts) # Join takes 2 + return Parts + + +class Place_Power(Module): + """Power: no transition moves its tokens""" + + def init(self): + return 1 + + def next(self, Power): + return Power + + +class Place_Jam(Module): + """Jam: no transition moves its tokens""" + + def init(self): + return 0 + + def next(self, Jam): + return Jam + + +class Place_Products(Module): + """Products: added by Join""" + + def init(self, fire_Join): + return 0 + + def next(self, Products, fire_Join): + Products = ite(X(fire_Join), Products + 1, Products) # Join adds 1 + return Products + + +transition_Join = Transition_Join(theory=LIA, ctrl=(fire_Join,), extl=(Parts, Power, Jam)) +place_Parts = Place_Parts(theory=LIA, ctrl=(Parts,), extl=(fire_Join,)) +place_Power = Place_Power(theory=LIA, ctrl=(Power,)) +place_Jam = Place_Jam(theory=LIA, ctrl=(Jam,)) +place_Products = Place_Products(theory=LIA, ctrl=(Products,), extl=(fire_Join,)) +net = compose(transition_Join, place_Parts, place_Power, place_Jam, place_Products) diff --git a/pocs/zeroth-playground/examples/__snapshots__/birth-death-clocked/net.py b/pocs/zeroth-playground/examples/__snapshots__/birth-death-clocked/net.py new file mode 100644 index 00000000..33b613ba --- /dev/null +++ b/pocs/zeroth-playground/examples/__snapshots__/birth-death-clocked/net.py @@ -0,0 +1,64 @@ +from zrth import SPN, Clock, Event, Nat, Var +from zrth import Module as compose +from zrth.sugar import Module, d, ite, fired, exp + +t = Var(Clock()) # the time reference + +Population = Var(Nat()) + +clk_Birth = Var(Clock()) # time left until Birth fires +clk_Death = Var(Clock()) # time left until Death fires + +ev_Birth = Var(Event()) # toggles when Birth fires +ev_Death = Var(Event()) # toggles when Death fires + + +class Transition_Birth(Module): + """Birth: nothing -> Population, at rate 2""" + + def init(self, t): + return exp(2.0), False + + def next(self, clk_Birth, ev_Birth, t): + fires_Birth = clk_Birth == 0 + return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) + + def flow(self, clk_Birth, ev_Birth, t): + return ite(clk_Birth >= 0, -1 * d(t), None), 0 + + +class Transition_Death(Module): + """Death: Population -> nothing, at rate 1""" + + def init(self, Population, t): + return exp(1.0), False + + def next(self, clk_Death, ev_Death, Population, t): + fires_Death = (clk_Death == 0) & (Population != 0) + return ite(fires_Death, exp(1.0), clk_Death), ite(fires_Death, ~ev_Death, None) + + def flow(self, clk_Death, ev_Death, Population, t): + return ite(clk_Death >= 0, ite(Population != 0, -1 * d(t), 0 * d(t)), None), 0 + + +class Place_Population(Module): + """Population: added by Birth, taken by Death""" + + def init(self, ev_Birth, ev_Death): + return 0 + + def next(self, Population, ev_Birth, ev_Death): + fired_Birth = fired(ev_Birth) + fired_Death = fired(ev_Death) + return ite(fired_Birth & ~fired_Death, Population + 1, ite(fired_Death & ~fired_Birth & (Population != 0), Population - 1, Population)) + + +transition_Birth = Transition_Birth(theory=SPN, ctrl=(clk_Birth, ev_Birth), extl=(t,)) +transition_Death = Transition_Death(theory=SPN, ctrl=(clk_Death, ev_Death), extl=(Population, t)) +place_Population = Place_Population(theory=SPN, ctrl=(Population,), extl=(ev_Birth, ev_Death)) +net = compose( + transition_Birth, + transition_Death, + place_Population, + hide={clk_Birth, clk_Death}, +) diff --git a/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py b/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py index c4c3d2d9..ed31aa5c 100644 --- a/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py +++ b/pocs/zeroth-playground/examples/__snapshots__/birth-death/net.py @@ -21,7 +21,7 @@ def init(self, u_Birth): return False def next(self, fire_Birth, u_Birth): - return X(u_Birth) >= 0.1353352832366127 + return X(u_Birth) >= 0.36787944117144233 class Transition_Death(Module): @@ -31,7 +31,7 @@ def init(self, Population, u_Death): return False def next(self, fire_Death, Population, u_Death): - return (Population >= 1.0) & (X(u_Death) >= 0.36787944117144233) + return (Population >= 1.0) & (X(u_Death) >= 0.6065306597126334) class Place_Population(Module): diff --git a/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py b/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py index ad878f29..a11cfe87 100644 --- a/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py +++ b/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py @@ -1,108 +1,119 @@ -from zrth import LRA, Bool, Real, Var +from zrth import SPN, Clock, Event, Nat, Var from zrth import Module as compose -from zrth.sugar import Module, X, ite +from zrth.sugar import Module, d, ite, fired, exp -REAL = Real([1, 1]) -BOOL = Bool([1, 1]) +t = Var(Clock()) # the time reference -Waiting = Var(REAL) -FreeStaff = Var(REAL) -Serving = Var(REAL) -Served = Var(REAL) +Waiting = Var(Nat()) +FreeStaff = Var(Nat()) +Serving = Var(Nat()) +Served = Var(Nat()) -u_Arrive = Var(REAL) # uniform draw for Arrive, each step -u_BeginService = Var(REAL) # uniform draw for BeginService, each step -u_FinishService = Var(REAL) # uniform draw for FinishService, each step +clk_Arrive = Var(Clock()) # time left until Arrive fires +clk_BeginService = Var(Clock()) # time left until BeginService fires +clk_FinishService = Var(Clock()) # time left until FinishService fires -fire_Arrive = Var(BOOL) # Arrive fires this step -fire_BeginService = Var(BOOL) # BeginService fires this step -fire_FinishService = Var(BOOL) # FinishService fires this step +ev_Arrive = Var(Event()) # toggles when Arrive fires +ev_BeginService = Var(Event()) # toggles when BeginService fires +ev_FinishService = Var(Event()) # toggles when FinishService fires class Transition_Arrive(Module): """Arrive: nothing -> Waiting, at rate 1.2""" - def init(self, u_Arrive): - return False + def init(self, t): + return exp(1.2), False - def next(self, fire_Arrive, u_Arrive): - return X(u_Arrive) >= 0.3011942119122021 + def next(self, clk_Arrive, ev_Arrive, t): + fires_Arrive = clk_Arrive == 0 + return ite(fires_Arrive, exp(1.2), clk_Arrive), ite(fires_Arrive, ~ev_Arrive, None) + + def flow(self, clk_Arrive, ev_Arrive, t): + return ite(clk_Arrive >= 0, -1 * d(t), None), 0 class Transition_BeginService(Module): """BeginService: Waiting, FreeStaff -> Serving, at rate 0.4""" - def init(self, Waiting, FreeStaff, u_BeginService): - return False + def init(self, Waiting, FreeStaff, t): + return exp(0.4), False + + def next(self, clk_BeginService, ev_BeginService, Waiting, FreeStaff, t): + fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) + return ite(fires_BeginService, exp(0.4), clk_BeginService), ite(fires_BeginService, ~ev_BeginService, None) - def next(self, fire_BeginService, Waiting, FreeStaff, u_BeginService): - return (Waiting >= 1.0) & (FreeStaff >= 1.0) & (X(u_BeginService) >= 0.6703200460356393) + def flow(self, clk_BeginService, ev_BeginService, Waiting, FreeStaff, t): + return ite(clk_BeginService >= 0, ite((Waiting != 0) & (FreeStaff != 0), -1 * d(t), 0 * d(t)), None), 0 class Transition_FinishService(Module): """FinishService: Serving -> Served, FreeStaff, at rate 0.5""" - def init(self, Serving, u_FinishService): - return False + def init(self, Serving, t): + return exp(0.5), False + + def next(self, clk_FinishService, ev_FinishService, Serving, t): + fires_FinishService = (clk_FinishService == 0) & (Serving != 0) + return ite(fires_FinishService, exp(0.5), clk_FinishService), ite(fires_FinishService, ~ev_FinishService, None) - def next(self, fire_FinishService, Serving, u_FinishService): - return (Serving >= 1.0) & (X(u_FinishService) >= 0.6065306597126334) + def flow(self, clk_FinishService, ev_FinishService, Serving, t): + return ite(clk_FinishService >= 0, ite(Serving != 0, -1 * d(t), 0 * d(t)), None), 0 class Place_Waiting(Module): - """Waiting: taken by BeginService, added by Arrive""" + """Waiting: added by Arrive, taken by BeginService""" - def init(self, fire_Arrive, fire_BeginService): - return 3.0 + def init(self, ev_Arrive, ev_BeginService): + return 3 - def next(self, Waiting, fire_Arrive, fire_BeginService): - Waiting = ite(X(fire_Arrive), Waiting + 1.0, Waiting) # Arrive adds 1 - Waiting = ite(X(fire_BeginService), Waiting - 1.0, Waiting) # BeginService takes 1 - return Waiting + def next(self, Waiting, ev_Arrive, ev_BeginService): + fired_Arrive = fired(ev_Arrive) + fired_BeginService = fired(ev_BeginService) + return ite(fired_Arrive & ~fired_BeginService, Waiting + 1, ite(fired_BeginService & ~fired_Arrive & (Waiting != 0), Waiting - 1, Waiting)) class Place_FreeStaff(Module): - """FreeStaff: taken by BeginService, added by FinishService""" + """FreeStaff: added by FinishService, taken by BeginService""" - def init(self, fire_BeginService, fire_FinishService): - return 2.0 + def init(self, ev_FinishService, ev_BeginService): + return 2 - def next(self, FreeStaff, fire_BeginService, fire_FinishService): - FreeStaff = ite(X(fire_BeginService), FreeStaff - 1.0, FreeStaff) # BeginService takes 1 - FreeStaff = ite(X(fire_FinishService), FreeStaff + 1.0, FreeStaff) # FinishService adds 1 - return FreeStaff + def next(self, FreeStaff, ev_FinishService, ev_BeginService): + fired_FinishService = fired(ev_FinishService) + fired_BeginService = fired(ev_BeginService) + return ite(fired_FinishService & ~fired_BeginService, FreeStaff + 1, ite(fired_BeginService & ~fired_FinishService & (FreeStaff != 0), FreeStaff - 1, FreeStaff)) class Place_Serving(Module): - """Serving: taken by FinishService, added by BeginService""" + """Serving: added by BeginService, taken by FinishService""" - def init(self, fire_BeginService, fire_FinishService): - return 0.0 + def init(self, ev_BeginService, ev_FinishService): + return 0 - def next(self, Serving, fire_BeginService, fire_FinishService): - Serving = ite(X(fire_BeginService), Serving + 1.0, Serving) # BeginService adds 1 - Serving = ite(X(fire_FinishService), Serving - 1.0, Serving) # FinishService takes 1 - return Serving + def next(self, Serving, ev_BeginService, ev_FinishService): + fired_BeginService = fired(ev_BeginService) + fired_FinishService = fired(ev_FinishService) + return ite(fired_BeginService & ~fired_FinishService, Serving + 1, ite(fired_FinishService & ~fired_BeginService & (Serving != 0), Serving - 1, Serving)) class Place_Served(Module): """Served: added by FinishService""" - def init(self, fire_FinishService): - return 0.0 + def init(self, ev_FinishService): + return 0 - def next(self, Served, fire_FinishService): - Served = ite(X(fire_FinishService), Served + 1.0, Served) # FinishService adds 1 - return Served + def next(self, Served, ev_FinishService): + fired_FinishService = fired(ev_FinishService) + return ite(fired_FinishService, Served + 1, Served) -transition_Arrive = Transition_Arrive(theory=LRA, ctrl=(fire_Arrive,), extl=(u_Arrive,)) -transition_BeginService = Transition_BeginService(theory=LRA, ctrl=(fire_BeginService,), extl=(Waiting, FreeStaff, u_BeginService)) -transition_FinishService = Transition_FinishService(theory=LRA, ctrl=(fire_FinishService,), extl=(Serving, u_FinishService)) -place_Waiting = Place_Waiting(theory=LRA, ctrl=(Waiting,), extl=(fire_Arrive, fire_BeginService)) -place_FreeStaff = Place_FreeStaff(theory=LRA, ctrl=(FreeStaff,), extl=(fire_BeginService, fire_FinishService)) -place_Serving = Place_Serving(theory=LRA, ctrl=(Serving,), extl=(fire_BeginService, fire_FinishService)) -place_Served = Place_Served(theory=LRA, ctrl=(Served,), extl=(fire_FinishService,)) +transition_Arrive = Transition_Arrive(theory=SPN, ctrl=(clk_Arrive, ev_Arrive), extl=(t,)) +transition_BeginService = Transition_BeginService(theory=SPN, ctrl=(clk_BeginService, ev_BeginService), extl=(Waiting, FreeStaff, t)) +transition_FinishService = Transition_FinishService(theory=SPN, ctrl=(clk_FinishService, ev_FinishService), extl=(Serving, t)) +place_Waiting = Place_Waiting(theory=SPN, ctrl=(Waiting,), extl=(ev_Arrive, ev_BeginService)) +place_FreeStaff = Place_FreeStaff(theory=SPN, ctrl=(FreeStaff,), extl=(ev_FinishService, ev_BeginService)) +place_Serving = Place_Serving(theory=SPN, ctrl=(Serving,), extl=(ev_BeginService, ev_FinishService)) +place_Served = Place_Served(theory=SPN, ctrl=(Served,), extl=(ev_FinishService,)) net = compose( transition_Arrive, transition_BeginService, @@ -111,4 +122,5 @@ def next(self, Served, fire_FinishService): place_FreeStaff, place_Serving, place_Served, + hide={clk_Arrive, clk_BeginService, clk_FinishService}, ) diff --git a/pocs/zeroth-playground/examples/__snapshots__/fork-clocked/net.py b/pocs/zeroth-playground/examples/__snapshots__/conflict-clocked/net.py similarity index 100% rename from pocs/zeroth-playground/examples/__snapshots__/fork-clocked/net.py rename to pocs/zeroth-playground/examples/__snapshots__/conflict-clocked/net.py diff --git a/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py b/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py index 89b096d0..4e56f535 100644 --- a/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py +++ b/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py @@ -9,52 +9,68 @@ Left = Var(INT) Right = Var(INT) +pick_TakeLeft = Var(BOOL) # the environment lets TakeLeft fire this step +pick_TakeRight = Var(BOOL) # the environment lets TakeRight fire this step + fire_TakeLeft = Var(BOOL) # TakeLeft fires this step fire_TakeRight = Var(BOOL) # TakeRight fires this step +fire_Return = Var(BOOL) # Return fires this step class Transition_TakeLeft(Module): - """TakeLeft: 2 Pool -> Left""" + """TakeLeft: Pool -> Left""" - def init(self, Pool): + def init(self, Pool, pick_TakeLeft): return False - def next(self, fire_TakeLeft, Pool): - return Pool >= 2 + def next(self, fire_TakeLeft, Pool, pick_TakeLeft): + return (Pool >= 1) & X(pick_TakeLeft) class Transition_TakeRight(Module): """TakeRight: Pool -> Right""" - def init(self, Pool, fire_TakeLeft): + def init(self, Pool, fire_TakeLeft, pick_TakeRight): return False - def next(self, fire_TakeRight, Pool, fire_TakeLeft): + def next(self, fire_TakeRight, Pool, fire_TakeLeft, pick_TakeRight): avail_Pool = Pool - avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 2, avail_Pool) # TakeLeft took 2 - return avail_Pool >= 1 + avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 1, avail_Pool) # TakeLeft took 1 + return (avail_Pool >= 1) & X(pick_TakeRight) + + +class Transition_Return(Module): + """Return: Left -> Pool""" + + def init(self, Left): + return False + + def next(self, fire_Return, Left): + return Left >= 1 class Place_Pool(Module): - """Pool: taken by TakeLeft, TakeRight""" + """Pool: taken by TakeLeft, TakeRight, added by Return""" - def init(self, fire_TakeLeft, fire_TakeRight): + def init(self, fire_TakeLeft, fire_TakeRight, fire_Return): return 3 - def next(self, Pool, fire_TakeLeft, fire_TakeRight): - Pool = ite(X(fire_TakeLeft), Pool - 2, Pool) # TakeLeft takes 2 + def next(self, Pool, fire_TakeLeft, fire_TakeRight, fire_Return): + Pool = ite(X(fire_TakeLeft), Pool - 1, Pool) # TakeLeft takes 1 Pool = ite(X(fire_TakeRight), Pool - 1, Pool) # TakeRight takes 1 + Pool = ite(X(fire_Return), Pool + 1, Pool) # Return adds 1 return Pool class Place_Left(Module): - """Left: added by TakeLeft""" + """Left: taken by Return, added by TakeLeft""" - def init(self, fire_TakeLeft): + def init(self, fire_TakeLeft, fire_Return): return 0 - def next(self, Left, fire_TakeLeft): + def next(self, Left, fire_TakeLeft, fire_Return): Left = ite(X(fire_TakeLeft), Left + 1, Left) # TakeLeft adds 1 + Left = ite(X(fire_Return), Left - 1, Left) # Return takes 1 return Left @@ -69,14 +85,16 @@ def next(self, Right, fire_TakeRight): return Right -transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool,)) -transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft)) -place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight)) -place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft,)) +transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool, pick_TakeLeft)) +transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft, pick_TakeRight)) +transition_Return = Transition_Return(theory=LIA, ctrl=(fire_Return,), extl=(Left,)) +place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight, fire_Return)) +place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft, fire_Return)) place_Right = Place_Right(theory=LIA, ctrl=(Right,), extl=(fire_TakeRight,)) net = compose( transition_TakeLeft, transition_TakeRight, + transition_Return, place_Pool, place_Left, place_Right, diff --git a/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py b/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py index 84f91f6e..7d4ffe72 100644 --- a/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py +++ b/pocs/zeroth-playground/examples/__snapshots__/cycle/net.py @@ -1,63 +1,28 @@ -from zrth import LIA, Bool, Int, Var -from zrth import Module as compose -from zrth.sugar import Module, X, ite +from zrth import LIA, Int, Var +from zrth.sugar import Module, ite INT = Int([1, 1]) -BOOL = Bool([1, 1]) A = Var(INT) B = Var(INT) -fire_Go = Var(BOOL) # Go fires this step -fire_Back = Var(BOOL) # Back fires this step +class Cycle(Module): + """Plain Petri net with 2 places and 2 transitions. One next is one step of the net.""" -class Transition_Go(Module): - """Go: A -> B""" + def init(self): + return 1, 0 - def init(self, A): - return False + def next(self, A, B): + # sweep in order; a firing consumes its input tokens at once + fire_Go = A >= 1 # Go: A -> B + A = ite(fire_Go, A - 1, A) + fire_Back = B >= 1 # Back: B -> A + B = ite(fire_Back, B - 1, B) + # end of step: produced tokens land + A = ite(fire_Back, A + 1, A) + B = ite(fire_Go, B + 1, B) + return A, B - def next(self, fire_Go, A): - return A >= 1 - -class Transition_Back(Module): - """Back: B -> A""" - - def init(self, B): - return False - - def next(self, fire_Back, B): - return B >= 1 - - -class Place_A(Module): - """A: taken by Go, added by Back""" - - def init(self, fire_Go, fire_Back): - return 1 - - def next(self, A, fire_Go, fire_Back): - A = ite(X(fire_Go), A - 1, A) # Go takes 1 - A = ite(X(fire_Back), A + 1, A) # Back adds 1 - return A - - -class Place_B(Module): - """B: taken by Back, added by Go""" - - def init(self, fire_Go, fire_Back): - return 0 - - def next(self, B, fire_Go, fire_Back): - B = ite(X(fire_Go), B + 1, B) # Go adds 1 - B = ite(X(fire_Back), B - 1, B) # Back takes 1 - return B - - -transition_Go = Transition_Go(theory=LIA, ctrl=(fire_Go,), extl=(A,)) -transition_Back = Transition_Back(theory=LIA, ctrl=(fire_Back,), extl=(B,)) -place_A = Place_A(theory=LIA, ctrl=(A,), extl=(fire_Go, fire_Back)) -place_B = Place_B(theory=LIA, ctrl=(B,), extl=(fire_Go, fire_Back)) -net = compose(transition_Go, transition_Back, place_A, place_B) +net = Cycle(theory=LIA, ctrl=(A, B)) diff --git a/pocs/zeroth-playground/examples/__snapshots__/fork/net.py b/pocs/zeroth-playground/examples/__snapshots__/fork/net.py deleted file mode 100644 index 38d06f52..00000000 --- a/pocs/zeroth-playground/examples/__snapshots__/fork/net.py +++ /dev/null @@ -1,98 +0,0 @@ -from zrth import LIA, Bool, Int, Var -from zrth import Module as compose -from zrth.sugar import Module, X, ite - -INT = Int([1, 1]) -BOOL = Bool([1, 1]) - -Pool = Var(INT) -Left = Var(INT) -Right = Var(INT) - -fire_TakeLeft = Var(BOOL) # TakeLeft fires this step -fire_TakeRight = Var(BOOL) # TakeRight fires this step -fire_Return = Var(BOOL) # Return fires this step - - -class Transition_TakeLeft(Module): - """TakeLeft: Pool -> Left""" - - def init(self, Pool): - return False - - def next(self, fire_TakeLeft, Pool): - return Pool >= 1 - - -class Transition_TakeRight(Module): - """TakeRight: Pool -> Right""" - - def init(self, Pool, fire_TakeLeft): - return False - - def next(self, fire_TakeRight, Pool, fire_TakeLeft): - avail_Pool = Pool - avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 1, avail_Pool) # TakeLeft took 1 - return avail_Pool >= 1 - - -class Transition_Return(Module): - """Return: Left -> Pool""" - - def init(self, Left): - return False - - def next(self, fire_Return, Left): - return Left >= 1 - - -class Place_Pool(Module): - """Pool: taken by TakeLeft, TakeRight, added by Return""" - - def init(self, fire_TakeLeft, fire_TakeRight, fire_Return): - return 3 - - def next(self, Pool, fire_TakeLeft, fire_TakeRight, fire_Return): - Pool = ite(X(fire_TakeLeft), Pool - 1, Pool) # TakeLeft takes 1 - Pool = ite(X(fire_TakeRight), Pool - 1, Pool) # TakeRight takes 1 - Pool = ite(X(fire_Return), Pool + 1, Pool) # Return adds 1 - return Pool - - -class Place_Left(Module): - """Left: taken by Return, added by TakeLeft""" - - def init(self, fire_TakeLeft, fire_Return): - return 0 - - def next(self, Left, fire_TakeLeft, fire_Return): - Left = ite(X(fire_TakeLeft), Left + 1, Left) # TakeLeft adds 1 - Left = ite(X(fire_Return), Left - 1, Left) # Return takes 1 - return Left - - -class Place_Right(Module): - """Right: added by TakeRight""" - - def init(self, fire_TakeRight): - return 0 - - def next(self, Right, fire_TakeRight): - Right = ite(X(fire_TakeRight), Right + 1, Right) # TakeRight adds 1 - return Right - - -transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool,)) -transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft)) -transition_Return = Transition_Return(theory=LIA, ctrl=(fire_Return,), extl=(Left,)) -place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight, fire_Return)) -place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft, fire_Return)) -place_Right = Place_Right(theory=LIA, ctrl=(Right,), extl=(fire_TakeRight,)) -net = compose( - transition_TakeLeft, - transition_TakeRight, - transition_Return, - place_Pool, - place_Left, - place_Right, -) diff --git a/pocs/zeroth-playground/examples/__snapshots__/queue/net.py b/pocs/zeroth-playground/examples/__snapshots__/queue/net.py deleted file mode 100644 index 1c3cd160..00000000 --- a/pocs/zeroth-playground/examples/__snapshots__/queue/net.py +++ /dev/null @@ -1,65 +0,0 @@ -from zrth import LRA, Bool, Real, Var -from zrth import Module as compose -from zrth.sugar import Module, X, ite - -REAL = Real([1, 1]) -BOOL = Bool([1, 1]) - -Waiting = Var(REAL) -Served = Var(REAL) - -u_Arrive = Var(REAL) # uniform draw for Arrive, each step -u_Serve = Var(REAL) # uniform draw for Serve, each step - -fire_Arrive = Var(BOOL) # Arrive fires this step -fire_Serve = Var(BOOL) # Serve fires this step - - -class Transition_Arrive(Module): - """Arrive: nothing -> Waiting, at rate 2.5""" - - def init(self, Waiting, u_Arrive): - return False - - def next(self, fire_Arrive, Waiting, u_Arrive): - return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) - - -class Transition_Serve(Module): - """Serve: Waiting -> Served, at rate 3""" - - def init(self, Waiting, u_Serve): - return False - - def next(self, fire_Serve, Waiting, u_Serve): - return (Waiting >= 1.0) & (X(u_Serve) >= 0.22313016014842982) - - -class Place_Waiting(Module): - """Waiting: taken by Serve, added by Arrive""" - - def init(self, fire_Arrive, fire_Serve): - return 0.0 - - def next(self, Waiting, fire_Arrive, fire_Serve): - Waiting = ite(X(fire_Arrive), Waiting + 1.0, Waiting) # Arrive adds 1 - Waiting = ite(X(fire_Serve), Waiting - 1.0, Waiting) # Serve takes 1 - return Waiting - - -class Place_Served(Module): - """Served: added by Serve""" - - def init(self, fire_Serve): - return 0.0 - - def next(self, Served, fire_Serve): - Served = ite(X(fire_Serve), Served + 1.0, Served) # Serve adds 1 - return Served - - -transition_Arrive = Transition_Arrive(theory=LRA, ctrl=(fire_Arrive,), extl=(Waiting, u_Arrive)) -transition_Serve = Transition_Serve(theory=LRA, ctrl=(fire_Serve,), extl=(Waiting, u_Serve)) -place_Waiting = Place_Waiting(theory=LRA, ctrl=(Waiting,), extl=(fire_Arrive, fire_Serve)) -place_Served = Place_Served(theory=LRA, ctrl=(Served,), extl=(fire_Serve,)) -net = compose(transition_Arrive, transition_Serve, place_Waiting, place_Served) diff --git a/pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py b/pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py deleted file mode 100644 index 39124038..00000000 --- a/pocs/zeroth-playground/examples/__snapshots__/sir-model/net.py +++ /dev/null @@ -1,86 +0,0 @@ -from zrth import LRA, Bool, Real, Var -from zrth import Module as compose -from zrth.sugar import Module, X, ite - -REAL = Real([1, 1]) -BOOL = Bool([1, 1]) - -Susceptible = Var(REAL) -Infected = Var(REAL) -Recovered = Var(REAL) - -u_Infection = Var(REAL) # uniform draw for Infection, each step -u_Recovery = Var(REAL) # uniform draw for Recovery, each step - -fire_Infection = Var(BOOL) # Infection fires this step -fire_Recovery = Var(BOOL) # Recovery fires this step - - -class Transition_Infection(Module): - """Infection: Susceptible, Infected -> 2 Infected, at rate 3""" - - def init(self, Susceptible, Infected, u_Infection): - return False - - def next(self, fire_Infection, Susceptible, Infected, u_Infection): - return (Susceptible >= 1.0) & (Infected >= 1.0) & (X(u_Infection) >= 0.049787068367863944) - - -class Transition_Recovery(Module): - """Recovery: Infected -> Recovered, at rate 1""" - - def init(self, Infected, fire_Infection, u_Recovery): - return False - - def next(self, fire_Recovery, Infected, fire_Infection, u_Recovery): - avail_Infected = Infected - avail_Infected = ite(X(fire_Infection), avail_Infected - 1.0, avail_Infected) # Infection took 1 - return (avail_Infected >= 1.0) & (X(u_Recovery) >= 0.36787944117144233) - - -class Place_Susceptible(Module): - """Susceptible: taken by Infection""" - - def init(self, fire_Infection): - return 9.0 - - def next(self, Susceptible, fire_Infection): - Susceptible = ite(X(fire_Infection), Susceptible - 1.0, Susceptible) # Infection takes 1 - return Susceptible - - -class Place_Infected(Module): - """Infected: taken by Recovery, added by Infection""" - - def init(self, fire_Infection, fire_Recovery): - return 1.0 - - def next(self, Infected, fire_Infection, fire_Recovery): - Infected = ite(X(fire_Infection), Infected + 1.0, Infected) # Infection adds 1 - Infected = ite(X(fire_Recovery), Infected - 1.0, Infected) # Recovery takes 1 - return Infected - - -class Place_Recovered(Module): - """Recovered: added by Recovery""" - - def init(self, fire_Recovery): - return 0.0 - - def next(self, Recovered, fire_Recovery): - Recovered = ite(X(fire_Recovery), Recovered + 1.0, Recovered) # Recovery adds 1 - return Recovered - - -transition_Infection = Transition_Infection(theory=LRA, ctrl=(fire_Infection,), extl=(Susceptible, Infected, u_Infection)) -transition_Recovery = Transition_Recovery(theory=LRA, ctrl=(fire_Recovery,), extl=(Infected, fire_Infection, u_Recovery)) -place_Susceptible = Place_Susceptible(theory=LRA, ctrl=(Susceptible,), extl=(fire_Infection,)) -place_Infected = Place_Infected(theory=LRA, ctrl=(Infected,), extl=(fire_Infection, fire_Recovery)) -place_Recovered = Place_Recovered(theory=LRA, ctrl=(Recovered,), extl=(fire_Recovery,)) -net = compose( - transition_Infection, - transition_Recovery, - place_Susceptible, - place_Infected, - place_Recovered, -) diff --git a/pocs/zeroth-playground/examples/fork/meta.ts b/pocs/zeroth-playground/examples/arcs/meta.ts similarity index 51% rename from pocs/zeroth-playground/examples/fork/meta.ts rename to pocs/zeroth-playground/examples/arcs/meta.ts index a5aca010..18f52ee3 100644 --- a/pocs/zeroth-playground/examples/fork/meta.ts +++ b/pocs/zeroth-playground/examples/arcs/meta.ts @@ -1,9 +1,9 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Leaving a conflict open", - title: "Fork", - summary: "Only the transitions in a conflict get picks.", + feature: "Weighted, read and inhibitor arcs", + title: "Arcs", + summary: "An arc that takes two tokens, one that reads a place, one that needs it empty.", rung: "steps", order: 40, listed: true, diff --git a/pocs/zeroth-playground/examples/arcs/net.pn.yaml b/pocs/zeroth-playground/examples/arcs/net.pn.yaml new file mode 100644 index 00000000..10c2b3ef --- /dev/null +++ b/pocs/zeroth-playground/examples/arcs/net.pn.yaml @@ -0,0 +1,24 @@ +name: arcs +kind: plain + +places: + Parts: + Power: + Jam: + Products: + +transitions: + Join: + inputs: + Parts: + weight: 2 + Power: + kind: read + Jam: + kind: inhibitor + outputs: + Products: + +marking: + Parts: 5 + Power: 1 diff --git a/pocs/zeroth-playground/examples/arcs/page.mdx b/pocs/zeroth-playground/examples/arcs/page.mdx new file mode 100644 index 00000000..c32b6807 --- /dev/null +++ b/pocs/zeroth-playground/examples/arcs/page.mdx @@ -0,0 +1,25 @@ +An arc can do more than take one token. `Join` takes two `Parts` through an arc of weight 2, reads `Power` without taking from it, and fires only while `Jam` is empty. + +`Transition_Join` tests the three arcs in one guard: + +```python +return (Parts >= 2) & (Power >= 1) & (Jam < 1) +``` + +- An arc of weight w tests `P >= w` and takes w tokens: `Place_Parts` applies `Parts - 2`. +- A read arc tests `P >= w` and takes nothing. +- An inhibitor arc tests `P < w`. + +The read and inhibitor arcs move no token, so `Place_Power` and `Place_Jam` return their count unchanged. Add `Jam: 1` to the marking, and `Join` never fires. + +Clock rates test a read arc as `P != 0` and an inhibitor arc as `P == 0`. They refuse an arc of weight above 1 with `clocks-arc-weight`, because an SPN event moves one token per arc. + +### Open questions + + + Should read and inhibitor arcs count as conflicts? Today any two transitions that share an input place get picks under **Conflicts** on nondet, whatever the arc kinds. So two readers of one place get picks, although neither takes a token. + + + + How should SPN express an arc of weight 2: `(n + 1) + 1`, an `Inc` of `Inc`, or a count on `Inc` and `Dec`? + diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.css b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.css rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx similarity index 98% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx index 85ea3601..4e5722f9 100644 --- a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer.tsx +++ b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx @@ -40,7 +40,7 @@ function verdictOf(dt: number, draw: number | undefined): string { * curve, and the rate the coin reaches when it fires at most once per step. */ export const CoinClockExplorer: React.FC = () => { - const [dt, setDt] = useState(1); + const [dt, setDt] = useState(0.5); const [draw, setDraw] = useState(undefined); const theta = threshold(LAMBDA, dt); diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.test.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.test.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.test.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.test.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/exponential.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/format.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/format.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/format.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/format.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.test.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.test.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.test.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.test.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/modifier-class.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.test.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.test.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.test.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.test.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/plot-frame.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.ts diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/range-field.tsx b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/range-field.tsx similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/range-field.tsx rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/range-field.tsx diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/survival-plot.tsx b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/survival-plot.tsx similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/survival-plot.tsx rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/survival-plot.tsx diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/use-width.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/use-width.ts similarity index 100% rename from pocs/zeroth-playground/examples/birth-death/components/coin-clock-explorer/use-width.ts rename to pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/use-width.ts diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts b/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts new file mode 100644 index 00000000..c9f697b0 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts @@ -0,0 +1,11 @@ +import type { ExampleMeta } from "../catalog"; + +export default { + feature: "Rates as clocks", + title: "Birth–death", + summary: "A rate becomes a clock that runs down in continuous time.", + rung: "rates", + order: 60, + listed: true, + options: { rates: "clock" }, +} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml b/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml new file mode 100644 index 00000000..f3af5f69 --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml @@ -0,0 +1,16 @@ +name: birth_death +description: 'Births add to the population at rate 2, and each death removes one member at rate 1 while any remain.' +kind: stochastic + +places: + Population: + +transitions: + Birth: + outputs: + Population: + rate: 2 + Death: + inputs: + Population: + rate: 1 diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx b/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx new file mode 100644 index 00000000..5bf1357d --- /dev/null +++ b/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx @@ -0,0 +1,46 @@ +import { CoinClockExplorer } from "./components/coin-clock-explorer"; + +Under clocks, each transition owns a clock armed with an exponential delay at its rate. Time flows until a clock reaches 0, then that transition fires and its clock re-arms. + +The compiler writes this in Zeroth's SPN theory. Each transition gets a clock `clk_T` and an event `ev_T` that toggles when it fires. `Transition_Birth` reads: + +```python +fires_Birth = clk_Birth == 0 +return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) +``` + +An `ite` with `None` as its last branch has no value where its condition fails, so the event toggles only when `Birth` fires. The `flow` method runs the clock down against the time `t` while it is not negative, and holds the event still with `0`: + +```python +return ite(clk_Birth >= 0, -1 * d(t), None), 0 +``` + +Each place is a Nat. `Place_Population` adds or takes one token when `fired` sees the event of `Birth` or `Death`. `compose` hides the clocks: `hide={clk_Birth, clk_Death}`. + +`Death` also needs a token in `Population`, so its clock runs down only while the place holds one: + +```python +ite(Population != 0, -1 * d(t), 0 * d(t)) +``` + +*Two inputs under clocks* shows where that choice matters. + +A coin reads its draw $u$ as the delay $\tau = -\ln(u)/\lambda$ of the clock it stands for, and $u \ge \theta$ exactly when $\tau \le \Delta t$. But a clock can expire twice in one step, while a coin fires at most once, so the coin's rate $p/\Delta t$ stays below $\lambda$. + +
    + +
    + +### Options + +- **Rates** on coin compiles the net in steps, as *Rates as coins* shows. + +### Open questions + + + Should a coin step allow ties? In one step `Birth` and `Death` can both fire, and the coins' `Place_Population` applies both. Under clocks two expiries at one instant have chance zero, and the place then applies neither. Both rules leave the same marking here. But where one firing disables another by adding a token, as through an inhibitor arc, the coin step fires both and clocks fire one. + + + + Can SPN take a rate that reads the marking, such as a death rate per member of `Population`? `Exp` takes a constant today, so `Death` fires at rate 1 however many members remain. Under coins such a rate is code, which this playground does not parse. + diff --git a/pocs/zeroth-playground/examples/queue/components/coin-draws.css b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css similarity index 95% rename from pocs/zeroth-playground/examples/queue/components/coin-draws.css rename to pocs/zeroth-playground/examples/birth-death/components/coin-draws.css index fc4be533..4c4ca967 100644 --- a/pocs/zeroth-playground/examples/queue/components/coin-draws.css +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css @@ -1,4 +1,4 @@ -/* -- Arrive's coin: one draw per step on [0, 1], against the threshold ----- */ +/* -- Birth's coin: one draw per step on [0, 1], against the threshold ------ */ .coin-draws { --coin-step: 1.6s; diff --git a/pocs/zeroth-playground/examples/queue/components/coin-draws.tsx b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx similarity index 88% rename from pocs/zeroth-playground/examples/queue/components/coin-draws.tsx rename to pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx index 46d66fa7..cd28c03b 100644 --- a/pocs/zeroth-playground/examples/queue/components/coin-draws.tsx +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx @@ -1,10 +1,10 @@ import "./coin-draws.css"; -/** Arrive's threshold at rate 2.5 over a step of 0.5: e^(-1.25). */ -const THRESHOLD = Math.exp(-2.5 * 0.5); +/** Birth's threshold at rate 2 over a step of 0.5: e^(-1). */ +const THRESHOLD = Math.exp(-2 * 0.5); -/** Four draws of u_Arrive, one per step: three reach the threshold, close to the chance 0.71. */ -const DRAWS = [0.1, 0.71, 0.4, 0.88]; +/** Four draws of u_Birth, one per step: three reach the threshold, close to the chance 0.63. */ +const DRAWS = [0.1, 0.71, 0.52, 0.88]; /** Where a value in [0, 1] sits along the axis, as a share of the drawing's width. */ function across(value: number): string { @@ -16,7 +16,7 @@ function verdict(u: number): "fires" | "waits" { } /** - * Arrive's coin over four steps: each step one draw lands on [0, 1], and the + * Birth's coin over four steps: each step one draw lands on [0, 1], and the * transition fires when it reaches the threshold. Without motion, the four * draws show at once. */ diff --git a/pocs/zeroth-playground/examples/birth-death/meta.ts b/pocs/zeroth-playground/examples/birth-death/meta.ts index d0b39ead..2b82fc7a 100644 --- a/pocs/zeroth-playground/examples/birth-death/meta.ts +++ b/pocs/zeroth-playground/examples/birth-death/meta.ts @@ -1,11 +1,11 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Coins against clocks", + feature: "Rates as coins", title: "Birth–death", - summary: "The same net in discrete steps and in continuous time.", + summary: "A rate becomes a draw tested against a threshold each step.", rung: "rates", - order: 60, + order: 50, listed: true, - options: { shape: "modular" }, + options: { shape: "modular", dt: 0.5 }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/birth-death/net.pn.yaml b/pocs/zeroth-playground/examples/birth-death/net.pn.yaml index 7203a41e..f3af5f69 100644 --- a/pocs/zeroth-playground/examples/birth-death/net.pn.yaml +++ b/pocs/zeroth-playground/examples/birth-death/net.pn.yaml @@ -1,4 +1,4 @@ -name: birth_death_process +name: birth_death description: 'Births add to the population at rate 2, and each death removes one member at rate 1 while any remain.' kind: stochastic diff --git a/pocs/zeroth-playground/examples/birth-death/page.mdx b/pocs/zeroth-playground/examples/birth-death/page.mdx index f9143b1d..60cdf86d 100644 --- a/pocs/zeroth-playground/examples/birth-death/page.mdx +++ b/pocs/zeroth-playground/examples/birth-death/page.mdx @@ -1,52 +1,27 @@ -import { CoinClockExplorer } from "./components/coin-clock-explorer"; +import { CoinDraws } from "./components/coin-draws"; -The compiler turns a rate into a coin tested each step, or into a clock in continuous time. The two agree draw by draw, but a coin fires at most once per step, so its rate stays below $\lambda$. +A transition at rate $\lambda$ fires within a step of length $\Delta t$ with probability $1 - e^{-\lambda \Delta t}$. The compiler turns that into a coin: an input draw `u_T` and the test `X(u_T) >= θ`, with $\theta = e^{-\lambda \Delta t}$ computed at compile time. -Under **Rates** on coin, the default, `Transition_Birth` tests a draw against $\theta = e^{-\lambda \Delta t}$ each step. For rate 2 over a step of 1, $\theta = e^{-2}$: +Over a step of 0.5, `Birth` at rate 2 gets $\theta = e^{-1} \approx 0.37$, so it fires in about 63% of steps: ```python -return X(u_Birth) >= 0.1353352832366127 +return X(u_Birth) >= 0.36787944117144233 ``` -Under **Rates** on clock, each transition owns a clock armed with `exp(rate)`. It fires when the clock reaches 0, then re-arms. The compiler writes that in the SPN theory: a clock `clk_T` and an event `ev_T` per transition, a Nat per place that moves when `fired` sees an event, and the clocks hidden in `compose`. `Transition_Birth` reads: - -```python -fires_Birth = clk_Birth == 0 -return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) -``` +
    + +
    -An `ite` with `None` as its last branch has no value where its condition fails: the event toggles when `Birth` fires and stays put otherwise. Its `flow` runs the clock down while it is not negative, and leaves the event still with `0`: +`Death` also needs a token in `Population`, so it tests the place beside its draw: ```python -return ite(clk_Birth >= 0, -1 * d(t), None), 0 +return (Population >= 1.0) & (X(u_Death) >= 0.6065306597126334) ``` - - - - - - - - - -
    CoinClock
    Timesteps of $\Delta t$continuous
    Fires when$u_T \ge \theta$`clk_T == 0`
    Firingsat most one per stepone per expiry
    - -A draw $u$ stands for the delay $\tau = -\ln(u)/\lambda$, and $u \ge \theta$ exactly when $\tau \le \Delta t$. So the coin fires exactly when the clock would expire within the step. A clock can expire twice in one step, though, while a coin fires once, so the coin's rate $p/\Delta t$ stays below $\lambda$. It tends to $\lambda$ as $\Delta t$ shrinks. - -
    - -
    - -Under clocks, `Death` pauses its clock while `Population` is empty. *Two inputs, one firing* shows where that choice matters. +The draws are Reals, so the places are Reals too and the modules use the theory LRA: `Population = Var(REAL)`, `Population + 1.0`. ### Options -- **Rates** on clock gives the SPN modules above. -- **Time step** sets $\Delta t$, and with it each coin's threshold. - -### Open questions - - - Should a coin step allow ties? In one step `Birth` and `Death` can both fire, and `Place_Population` applies both. Under clocks two expiries at one instant have chance zero, and the place then applies neither. Both rules leave the same marking here. But where one firing disables another by adding a token, as through an inhibitor arc, the coin step fires both and clocks fire one. - +- **Time step** sets $\Delta t$, and every threshold follows it. +- **Marking** on Int keeps the places Int and moves each draw into an LRA module, `Draw_Birth`, that writes a Bool `hit_Birth`. The guard reads `X(hit_Birth)` in place of the draw. +- **Rates** on clock compiles the net in continuous time, as *Rates as clocks* shows. diff --git a/pocs/zeroth-playground/examples/boiler/meta.ts b/pocs/zeroth-playground/examples/boiler/meta.ts deleted file mode 100644 index 969efe7d..00000000 --- a/pocs/zeroth-playground/examples/boiler/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "Continuous dynamics", - title: "Boiler", - summary: "An Euler step per token, and a read arc.", - rung: "colours", - order: 100, - listed: false, - options: { dt: 0.25 }, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/boiler/net.pn.yaml b/pocs/zeroth-playground/examples/boiler/net.pn.yaml deleted file mode 100644 index 7ca74f77..00000000 --- a/pocs/zeroth-playground/examples/boiler/net.pn.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: boiler -kind: plain - -colours: - Vessel: - level: real - -dynamics: - Heat: - colour: Vessel - code: | - return tokens.map((t) => ({ level: 0.5 * (10 - t.level) })); - -places: - Tank: - colour: Vessel - dynamics: Heat - capacity: 1 - Alarms: - -transitions: - Alarm: - inputs: - Tank: - kind: read - outputs: - Alarms: - guard: | - return input.Tank[0].level >= 8; - -marking: - Tank: - - {level: 0} diff --git a/pocs/zeroth-playground/examples/boiler/page.mdx b/pocs/zeroth-playground/examples/boiler/page.mdx deleted file mode 100644 index f8d3f697..00000000 --- a/pocs/zeroth-playground/examples/boiler/page.mdx +++ /dev/null @@ -1,29 +0,0 @@ -A token can carry a real attribute that follows a differential equation. `Tank` holds one `Vessel` token whose `level` $\ell$ follows $\dot{\ell} = 0.5\,(10 - \ell)$, and `Alarm` reads the tank through a read arc once the level reaches 8. - -### Refused here - -The equation and the guard are code strings, and the playground ships no code parser. So the compiler refuses the net with `code-not-parsed`, once per code string. - -With a parser, the monolithic step takes one Euler step of **Time step**, 0.25 here, for each token before the sweep: - -```python -Tank_0_level = ite(Tank_0_present, Tank_0_level + 0.125 * (10.0 - Tank_0_level), Tank_0_level) -``` - -The read arc becomes a test that takes no token: - -```python -fire_Alarm = Tank_0_present & (Tank_0_level >= 8.0) -``` - -**Shape** on modular refuses the net earlier, with `modular-coloured-not-lowered`, before it reads any code. - -### Open questions - - - Can an LRA flow read its own state, as $\dot{\ell} = f(\ell)$ needs? Until it can, dynamics take one Euler step per **Time step** inside `next`. - - - - How would SPN hold coloured tokens and dynamics: a `Real` sort, or an LRA module beside the SPN ones? Clocks do not apply to such nets today. - diff --git a/pocs/zeroth-playground/examples/bucket/meta.ts b/pocs/zeroth-playground/examples/bucket/meta.ts index ae3785ee..8f1ee515 100644 --- a/pocs/zeroth-playground/examples/bucket/meta.ts +++ b/pocs/zeroth-playground/examples/bucket/meta.ts @@ -1,11 +1,11 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Slots and compaction", + feature: "Code strings are refused", title: "Bucket", - summary: "A coloured place as a row of positions.", + summary: "A rate written as code waits for a code parser.", rung: "colours", - order: 120, + order: 90, listed: false, options: {}, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/bucket/page.mdx b/pocs/zeroth-playground/examples/bucket/page.mdx index 14eff2ab..8765ba94 100644 --- a/pocs/zeroth-playground/examples/bucket/page.mdx +++ b/pocs/zeroth-playground/examples/bucket/page.mdx @@ -1,8 +1,14 @@ -A coloured place becomes a row of positions. `Pool` holds two `Ball` tokens with a real `x`, and the rate of `Take` reads the first token: $\lambda = 0.6 \cdot x / 100$. +`Take` fires at a rate that reads a token's attribute, so the IR writes that rate as code. The compiler reads code only through a code parser it is given. + +`Pool` holds two `Ball` tokens with a real `x`, and the rate of `Take` reads the first token: + +```ts +return 0.6 * (input.Pool[0].x / 100); +``` ### Refused here -The rate is code, so the compiler refuses the net with `code-not-parsed`. +This playground passes no code parser, so the net is refused with `code-not-parsed`. A TypeScript parser does not fit its single HTML file. With a parser, the monolithic step gives each position of `Pool` a present flag and an `x`: `Pool_0_present` and `Pool_0_x`. `Take` tries the tokens in order and fires on the first whose rate reaches an exponential draw, `e_Take`. At the end of the step the surviving tokens close up in position order. diff --git a/pocs/zeroth-playground/examples/cafe-queue/meta.ts b/pocs/zeroth-playground/examples/cafe-queue/meta.ts index 6db474f0..e735305b 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/meta.ts +++ b/pocs/zeroth-playground/examples/cafe-queue/meta.ts @@ -1,11 +1,11 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Two inputs, one firing", + feature: "Two inputs under clocks", title: "Café queue", - summary: "A transition that takes from two places at once.", + summary: "A clock that runs only while both input places hold a token.", rung: "rates", order: 70, listed: true, - options: { shape: "modular" }, + options: { rates: "clock" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/cafe-queue/page.mdx b/pocs/zeroth-playground/examples/cafe-queue/page.mdx index 3adb8956..0767aec3 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/page.mdx +++ b/pocs/zeroth-playground/examples/cafe-queue/page.mdx @@ -1,23 +1,26 @@ -`BeginService` needs a customer in `Waiting` and a free member of staff in `FreeStaff`. Under coins that is two tests in one guard. Under clocks the compiler must also decide what an empty input does to the clock. +`BeginService` needs a customer in `Waiting` and a free member of staff in `FreeStaff`. Under clocks the compiler must decide what an empty input place does to the clock. -Under **Rates** on coin, the default, `Transition_BeginService` tests both places beside its draw: +The transition decides. `fires_BeginService` keeps both tests: ```python -return (Waiting >= 1.0) & (FreeStaff >= 1.0) & (X(u_BeginService) >= 0.6703200460356393) +fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) ``` -Under **Rates** on clock, the transition decides. `fires_BeginService` keeps both tests, and the clock runs down only while both places hold a token: +The clock runs down only while both places hold a token, and pauses otherwise: ```python -fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) +return ite(clk_BeginService >= 0, ite((Waiting != 0) & (FreeStaff != 0), -1 * d(t), 0 * d(t)), None), 0 ``` -Zeroth's own `birth_death.py` lets the places decide instead. The clock always runs, and at expiry each input place gives a token if it has one. With one input place the two rules agree. With two they do not: `BeginService` could take a customer from `Waiting` while `FreeStaff` is empty. +Zeroth's own `birth_death.py` lets the places decide instead. The clock always runs, and at expiry each input place gives a token if it has one. With one input place, as for `Death` on *Rates as clocks*, the two rules agree, since an exponential delay has no memory. With two they do not: `BeginService` could take a customer from `Waiting` while `FreeStaff` is empty. ### Options -- **Rates** on clock gives the SPN modules above. -- **Marking** on Int keeps the places Int, and the guard reads `X(hit_BeginService)`, a Bool from `Draw_BeginService`, in place of the draw. +- **Rates** on coin tests both places beside the draw, in one guard: + + ```python + return (Waiting >= 1.0) & (FreeStaff >= 1.0) & (X(u_BeginService) >= 0.6703200460356393) + ``` ### Open questions diff --git a/pocs/zeroth-playground/examples/capacity/meta.ts b/pocs/zeroth-playground/examples/capacity/meta.ts index 2ea1e9a9..1efb4f97 100644 --- a/pocs/zeroth-playground/examples/capacity/meta.ts +++ b/pocs/zeroth-playground/examples/capacity/meta.ts @@ -1,11 +1,11 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Capacity within a step", + feature: "A capped place", title: "Capacity", - summary: "A later producer sees the token an earlier one added.", + summary: "A later producer counts the token an earlier one adds in the same step.", rung: "steps", - order: 20, + order: 30, listed: true, options: { shape: "modular" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/capacity/page.mdx b/pocs/zeroth-playground/examples/capacity/page.mdx index 79c61631..0101da51 100644 --- a/pocs/zeroth-playground/examples/capacity/page.mdx +++ b/pocs/zeroth-playground/examples/capacity/page.mdx @@ -8,18 +8,20 @@ fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) return (RightSource >= 1) & (fill_Buffer + 1 <= 2) ``` -These are the lines of `Transition_PutRight`. It rebuilds the fill from `X(fire_PutLeft)`, while `Place_Buffer` alone writes `Buffer`. In the monolithic shape one `fill_Buffer` runs through the sweep, and every firing that moves tokens in `Buffer` updates it. +These are the lines of `Transition_PutRight`. It rebuilds the fill from `X(fire_PutLeft)` as a taker rebuilds `avail_Pool` on *Who gets the token*, while `Place_Buffer` alone writes `Buffer`. `Take` comes after both producers in record order, so the room it frees counts only from the next step. A full `Buffer` that `Take` drains in this step still blocks both producers. +Clock rates refuse a capacity with `clocks-capacity`, so a stochastic net with a capped place compiles under coins only. + ### Options -- **Shape** on monolithic gives one `Capacity` class: `fill_Buffer = Buffer` before the sweep, and the class returns `fill_Buffer` as the new `Buffer`. +- **Shape** on monolithic gives one `Capacity` class: `fill_Buffer = Buffer` before the sweep, every firing that moves tokens in `Buffer` updates it, and the class returns it as the new `Buffer`. ### Open questions - How can SPN express a capacity: an ordering on `Nat`, or a complement place? SPN tests a count against zero only, so **Rates** on clock refuses a capacity with `clocks-capacity`. + How can SPN express a capacity: an ordering on `Nat`, or a complement place? SPN tests a count against zero only. diff --git a/pocs/zeroth-playground/examples/catalog.ts b/pocs/zeroth-playground/examples/catalog.ts index cf218cd7..2ebdc0d5 100644 --- a/pocs/zeroth-playground/examples/catalog.ts +++ b/pocs/zeroth-playground/examples/catalog.ts @@ -3,15 +3,15 @@ import type { CompilerOptions } from "../compiler"; /** A rung of the ladder: the examples it holds tackle features of one kind. */ export type Rung = { id: "steps" | "rates" | "colours"; + /** The rung's heading in the picker. */ title: string; - about: string; }; -/** The ladder, from the plainest net up. */ +/** The ladder, from the plainest net up; examples/README.md says what each rung holds. */ export const LADDER: readonly Rung[] = [ - { id: "steps", title: "Steps", about: "Plain nets: how one step of the net becomes modules" }, - { id: "rates", title: "Rates", about: "Stochastic nets: coins tested each step, or clocks in continuous time" }, - { id: "colours", title: "Coloured tokens", about: "Attributes, dynamics and kernels; not compiled here yet" }, + { id: "steps", title: "Steps" }, + { id: "rates", title: "Rates" }, + { id: "colours", title: "Coloured tokens" }, ]; /** What an example's `meta.ts` declares. */ @@ -31,9 +31,8 @@ export type ExampleMeta = { */ listed: boolean; /** - * The compiler options the example opens with; the panel edits them from - * there. The playground opens a net in the modular shape wherever that shape - * compiles it: coloured nets and nets with dynamics stay monolithic. + * The compiler options the example opens with: those that show its + * feature. The panel edits them from there. */ options: CompilerOptions; }; @@ -52,7 +51,7 @@ export type Example = ExampleMeta & { const METAS = import.meta.glob("./*/meta.ts", { eager: true, import: "default" }); const NETS = import.meta.glob("./*/net.pn.yaml", { eager: true, query: "?raw", import: "default" }); -/** The folder a globbed path sits in: `./queue/meta.ts` is `queue`. */ +/** The folder a globbed path sits in: `./cycle/meta.ts` is `cycle`. */ export function folderOf(path: string): string { return path.split("/").at(-2) ?? path; } diff --git a/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.css b/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css similarity index 100% rename from pocs/zeroth-playground/examples/fork-clocked/components/clock-race.css rename to pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css diff --git a/pocs/zeroth-playground/examples/fork-clocked/components/clock-race.tsx b/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.tsx similarity index 100% rename from pocs/zeroth-playground/examples/fork-clocked/components/clock-race.tsx rename to pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.tsx diff --git a/pocs/zeroth-playground/examples/fork-clocked/meta.ts b/pocs/zeroth-playground/examples/conflict-clocked/meta.ts similarity index 86% rename from pocs/zeroth-playground/examples/fork-clocked/meta.ts rename to pocs/zeroth-playground/examples/conflict-clocked/meta.ts index f0d343a8..9549e635 100644 --- a/pocs/zeroth-playground/examples/fork-clocked/meta.ts +++ b/pocs/zeroth-playground/examples/conflict-clocked/meta.ts @@ -2,10 +2,10 @@ import type { ExampleMeta } from "../catalog"; export default { feature: "Conflicts under clocks", - title: "Fork under clocks", + title: "Conflict with rates", summary: "A conflict as a race between clocks, with picks read at expiry.", rung: "rates", - order: 90, + order: 80, listed: true, options: { rates: "clock", conflicts: "nondet" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml b/pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml similarity index 95% rename from pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml rename to pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml index e1b1808d..57a99ab9 100644 --- a/pocs/zeroth-playground/examples/fork-clocked/net.pn.yaml +++ b/pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml @@ -1,4 +1,4 @@ -name: fork +name: conflict kind: stochastic places: diff --git a/pocs/zeroth-playground/examples/fork-clocked/page.mdx b/pocs/zeroth-playground/examples/conflict-clocked/page.mdx similarity index 97% rename from pocs/zeroth-playground/examples/fork-clocked/page.mdx rename to pocs/zeroth-playground/examples/conflict-clocked/page.mdx index ebfd9c32..fb5b31e8 100644 --- a/pocs/zeroth-playground/examples/fork-clocked/page.mdx +++ b/pocs/zeroth-playground/examples/conflict-clocked/page.mdx @@ -25,7 +25,7 @@ So when both takers expire together, `Left` and `Right` each gain a token while ### Options - **Conflicts** on sweep drops the picks: the first clock to expire fires. -- **Rates** on coin compiles the same net in steps of **Time step**, with a coin per transition. +- **Rates** on coin compiles the same net in steps of **Time step**, as on *Who gets the token*, with a coin per transition. ### Open questions diff --git a/pocs/zeroth-playground/examples/conflict/meta.ts b/pocs/zeroth-playground/examples/conflict/meta.ts index 90d2151d..4d8e4dea 100644 --- a/pocs/zeroth-playground/examples/conflict/meta.ts +++ b/pocs/zeroth-playground/examples/conflict/meta.ts @@ -1,11 +1,11 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Two transitions, one place", + feature: "Who gets the token", title: "Conflict", - summary: "Who takes the tokens: the sweep, a pick or a controller.", + summary: "Record order settles a shared input place, or a pick per contender leaves it open.", rung: "steps", - order: 30, + order: 20, listed: true, - options: { shape: "modular" }, + options: { shape: "modular", conflicts: "nondet" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/conflict/net.pn.yaml b/pocs/zeroth-playground/examples/conflict/net.pn.yaml index a6f58905..5ac9dde0 100644 --- a/pocs/zeroth-playground/examples/conflict/net.pn.yaml +++ b/pocs/zeroth-playground/examples/conflict/net.pn.yaml @@ -10,7 +10,6 @@ transitions: TakeLeft: inputs: Pool: - weight: 2 outputs: Left: controllable: true @@ -19,6 +18,11 @@ transitions: Pool: outputs: Right: + Return: + inputs: + Left: + outputs: + Pool: marking: Pool: 3 diff --git a/pocs/zeroth-playground/examples/conflict/page.mdx b/pocs/zeroth-playground/examples/conflict/page.mdx index cb50a244..2783b937 100644 --- a/pocs/zeroth-playground/examples/conflict/page.mdx +++ b/pocs/zeroth-playground/examples/conflict/page.mdx @@ -1,36 +1,33 @@ -`TakeLeft` takes two tokens from `Pool` and `TakeRight` takes one, so when `Pool` runs low only one of them can fire. The compiler has to say which one gets the tokens. +`TakeLeft` and `TakeRight` both take from `Pool`, so when `Pool` holds one token only one of them can fire. The compiler has to say which one gets it. -By default, **Conflicts** on sweep, record order decides, as in one step of the net. `TakeLeft` tests `Pool >= 2`, and `TakeRight` tests what `TakeLeft` left, `avail_Pool`: +The step settles it in record order. `TakeLeft` comes first and tests `Pool`. `TakeRight` tests what `TakeLeft` left, `avail_Pool`: ```python avail_Pool = Pool -avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 2, avail_Pool) -return avail_Pool >= 1 +avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 1, avail_Pool) ``` -With **Conflicts** on nondet, each transition in the conflict also waits for an input Bool that nothing drives, `pick_TakeLeft` or `pick_TakeRight`. The two guards become: +So once `Pool` is down to one token, `TakeLeft` takes it in every step and `TakeRight` never fires again. + +**Conflicts** on nondet leaves the choice open. Each transition in the conflict also waits for an input Bool that nothing drives, `pick_TakeLeft` or `pick_TakeRight`: ```python -return (Pool >= 2) & X(pick_TakeLeft) +return (Pool >= 1) & X(pick_TakeLeft) return (avail_Pool >= 1) & X(pick_TakeRight) ``` -Each way of resolving the conflict is then a run of the modules, and all picks true is the sweep. +Every way of resolving the conflict is then a run of the modules, and all picks true is record order. So a property proved under nondet holds whichever transition gets the token. -`TakeLeft` is also controllable. **Control** on open adds the same kind of input to its guard, `X(go_TakeLeft)`. +`Return` takes from `Left`, which no other transition takes from. It is in no conflict, so it gets no pick, and it fires whenever `Left` holds a token. ### Options -- **Conflicts** on nondet adds the picks. -- **Control** on open adds `go_TakeLeft`. -- **Shape** on monolithic gives one `Conflict` class whose `next` takes from `Pool` in record order, with no `avail_Pool`. +- **Conflicts** on sweep drops the picks, and record order alone decides. +- **Control** on open gives `TakeLeft`, which the IR marks `controllable`, an input `go_TakeLeft` that nothing drives, of the same kind as a pick. +- **Shape** on monolithic needs no `avail_Pool`: one `next` takes from `Pool` in record order. ### Open questions - - Should read and inhibitor arcs count as conflicts? Today any two transitions that share an input place get picks, so two readers of one place get picks although they take nothing. - - Are **Control** on open and **Conflicts** on nondet one mechanism? Both add an input Bool that nothing drives to a guard, `X(go_T)` or `X(pick_T)`. One option for external choices could replace both. diff --git a/pocs/zeroth-playground/examples/cycle/meta.ts b/pocs/zeroth-playground/examples/cycle/meta.ts index 811247c3..71bb80eb 100644 --- a/pocs/zeroth-playground/examples/cycle/meta.ts +++ b/pocs/zeroth-playground/examples/cycle/meta.ts @@ -3,9 +3,9 @@ import type { ExampleMeta } from "../catalog"; export default { feature: "One step as a module", title: "Cycle", - summary: "How one step of a plain net becomes one module, or several.", + summary: "One step of a plain net as one class, or as a module per transition and place.", rung: "steps", order: 10, listed: true, - options: { shape: "modular" }, + options: {}, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/cycle/page.mdx b/pocs/zeroth-playground/examples/cycle/page.mdx index d8592ef4..f71b3878 100644 --- a/pocs/zeroth-playground/examples/cycle/page.mdx +++ b/pocs/zeroth-playground/examples/cycle/page.mdx @@ -6,7 +6,7 @@ One step of a plain net fires every enabled transition once, in record order. A
    -With **Shape** on monolithic, the compiler writes the step as one class, `Cycle`, with an Int per place. Its `next` method is one step of the net: +The compiler writes the step as one class, `Cycle`, with an Int per place. Its `next` method is one step of the net: ```python fire_Go = A >= 1 @@ -17,22 +17,17 @@ A = ite(fire_Back, A + 1, A) B = ite(fire_Go, B + 1, B) ``` -The first four lines test and take in record order. The last two land the produced tokens. +The first four lines test and take in record order. The last two land the produced tokens. Every later example compiles this step. -The playground opens the example with **Shape** on modular. It splits the same step into one module per transition and one per place, joined by `compose`. `Transition_Go` writes the Bool `fire_Go` as `A >= 1`. `Place_A` applies every firing that moves `A`: +**Shape** on modular splits the same step into one module per transition and one per place, joined by `compose`. `Transition_Go` writes the Bool `fire_Go` as `A >= 1`. `Place_A` applies every firing that moves `A`: ```python A = ite(X(fire_Go), A - 1, A) A = ite(X(fire_Back), A + 1, A) ``` -`X(fire_Go)` reads the value `fire_Go` takes in this step, so `Place_A` waits for `Transition_Go`. In Zeroth a variable has one writer, so each place gets a module of its own. - -### Options - -- **Shape** on monolithic gives the class `Cycle` above. -- **Files** on one per module writes `net.py` and a file per module, such as `transition_go.py`. +`X(fire_Go)` reads the value `fire_Go` takes in this step, so `Place_A` waits for `Transition_Go`. In Zeroth a variable has one writer, so each place gets a module of its own. The plain and coin examples after this one open in this shape. ### Hover -- Point at a line in either editor, or at a node in the preview, to light the lines and nodes that match it. `Go:` lights `fire_Go`, `Transition_Go` and the line that builds `transition_Go`. +- Point at a line in either editor, or at a node in the preview, to light the lines and nodes that match it. `Go:` lights the line of the step that sets `fire_Go`. diff --git a/pocs/zeroth-playground/examples/drones/meta.ts b/pocs/zeroth-playground/examples/drones/meta.ts deleted file mode 100644 index 509699e2..00000000 --- a/pocs/zeroth-playground/examples/drones/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "Tokens with attributes", - title: "Drones", - summary: "Coloured positions that kernels write.", - rung: "colours", - order: 110, - listed: false, - options: {}, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/drones/net.pn.yaml b/pocs/zeroth-playground/examples/drones/net.pn.yaml deleted file mode 100644 index 39090fc0..00000000 --- a/pocs/zeroth-playground/examples/drones/net.pn.yaml +++ /dev/null @@ -1,53 +0,0 @@ -name: drones -kind: mixed - -colours: - Drone: - battery: real - state: - enum: - - idle - - flying - -dynamics: - Drain: - colour: Drone - code: | - return tokens.map((d) => ({ battery: -2.5 })); - -places: - Hangar: - colour: Drone - capacity: 3 - Airborne: - colour: Drone - capacity: 3 - dynamics: Drain - Sorties: - -transitions: - Launch: - inputs: - Hangar: - outputs: - Airborne: - Sorties: - rate: | - return 0.6 * (input.Hangar[0].battery / 100); - kernel: | - const d = input.Hangar[0]; - return { Airborne: [{ battery: d.battery, state: "flying" }] }; - Land: - inputs: - Airborne: - outputs: - Hangar: - guard: | - return input.Airborne[0].battery < 20 || input.Airborne[0].state !== "flying"; - kernel: | - return { Hangar: [{ battery: 100, state: "idle" }] }; - -marking: - Hangar: - - {battery: 100, state: idle} - - {battery: 60, state: idle} diff --git a/pocs/zeroth-playground/examples/drones/page.mdx b/pocs/zeroth-playground/examples/drones/page.mdx deleted file mode 100644 index 0383cdcd..00000000 --- a/pocs/zeroth-playground/examples/drones/page.mdx +++ /dev/null @@ -1,19 +0,0 @@ -Tokens can carry attributes that kernels write. A `Drone` has a real `battery` and a `state`, `idle` or `flying`. `Launch` fires at a rate that reads the battery and writes a flying drone into `Airborne`. `Drain` lowers the battery of every airborne drone, and `Land` brings a drone back with a full battery. - -### Refused here - -The rate, the guard, the two kernels and the equation are code strings. So the compiler refuses the net with `code-not-parsed`: five diagnostics for three items. - -With a parser, the monolithic step lays each coloured place out as a row of positions. Each position has a present flag and one variable per attribute: `Hangar_0_present`, `Hangar_0_battery` and `Hangar_0_state`. The string `state` is held as a Real code, 0 for `idle` and 1 for `flying`. A kernel writes locals named `out_Launch_Airborne_0_*`, which land after the surviving tokens close up. - -**Shape** on modular refuses the net with `modular-coloured-not-lowered` alone, before it reads any code. - -### Open questions - - - How would a kernel compile under SPN, which has no sort for token attributes? - - - - `state` compiles because its colour lists its values. A `uuid` or an open string gets no variable, and code that reads one is refused with `attribute-not-lowerable`. Do proofs need them? - diff --git a/pocs/zeroth-playground/examples/examples.test.ts b/pocs/zeroth-playground/examples/examples.test.ts index 98785bac..c01a4d4d 100644 --- a/pocs/zeroth-playground/examples/examples.test.ts +++ b/pocs/zeroth-playground/examples/examples.test.ts @@ -19,19 +19,16 @@ describe("the examples", () => { files.length > 0 ? "compiles" : errors.map((error) => error.code).join(","), ]), ); - // THEN the plain and stochastic nets compile, and the nets with code wait for a parser + // THEN the plain and stochastic nets compile, and the net with code waits for a parser expect(outcomes).toEqual({ cycle: "compiles", - capacity: "compiles", conflict: "compiles", - fork: "compiles", - "fork-clocked": "compiles", - queue: "compiles", + capacity: "compiles", + arcs: "compiles", "birth-death": "compiles", + "birth-death-clocked": "compiles", "cafe-queue": "compiles", - "sir-model": "compiles", - boiler: "code-not-parsed,code-not-parsed", - drones: "code-not-parsed,code-not-parsed,code-not-parsed,code-not-parsed,code-not-parsed", + "conflict-clocked": "compiles", bucket: "code-not-parsed", }); }); diff --git a/pocs/zeroth-playground/examples/fork/net.pn.yaml b/pocs/zeroth-playground/examples/fork/net.pn.yaml deleted file mode 100644 index b1112f8a..00000000 --- a/pocs/zeroth-playground/examples/fork/net.pn.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: fork -kind: plain - -places: - Pool: - Left: - Right: - -transitions: - TakeLeft: - inputs: - Pool: - outputs: - Left: - TakeRight: - inputs: - Pool: - outputs: - Right: - Return: - inputs: - Left: - outputs: - Pool: - -marking: - Pool: 3 diff --git a/pocs/zeroth-playground/examples/fork/page.mdx b/pocs/zeroth-playground/examples/fork/page.mdx deleted file mode 100644 index 19db5264..00000000 --- a/pocs/zeroth-playground/examples/fork/page.mdx +++ /dev/null @@ -1,10 +0,0 @@ -A conflict involves only the transitions that share an input place. `TakeLeft` and `TakeRight` both take from `Pool`, while `Return` takes from `Left`, which no other transition takes from. - -So **Conflicts** on nondet gives a pick to `TakeLeft` and `TakeRight` and none to `Return`. `Transition_TakeLeft` adds `X(pick_TakeLeft)` to its test, and `Transition_Return` keeps its plain guard, `Left >= 1`. - -A property proved under nondet holds for every way the picks resolve the conflict, while `Return` fires whenever `Left` holds a token. Under the default, **Conflicts** on sweep, record order decides: `TakeRight` tests `avail_Pool`, the tokens `TakeLeft` left. - -### Options - -- **Conflicts** on nondet adds `pick_TakeLeft` and `pick_TakeRight`. -- **Shape** on monolithic gives one `Fork` class whose `next` takes from `Pool` in record order. diff --git a/pocs/zeroth-playground/examples/queue/meta.ts b/pocs/zeroth-playground/examples/queue/meta.ts deleted file mode 100644 index f548ffb6..00000000 --- a/pocs/zeroth-playground/examples/queue/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "Rates as coins", - title: "Queue", - summary: "A rate becomes a draw tested against a threshold each step.", - rung: "rates", - order: 50, - listed: true, - options: { shape: "modular", dt: 0.5 }, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/queue/net.pn.yaml b/pocs/zeroth-playground/examples/queue/net.pn.yaml deleted file mode 100644 index 2ec940fc..00000000 --- a/pocs/zeroth-playground/examples/queue/net.pn.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: queue -kind: stochastic - -places: - Waiting: - capacity: 4 - Served: - -transitions: - Arrive: - outputs: - Waiting: - rate: 2.5 - Serve: - inputs: - Waiting: - outputs: - Served: - rate: 3 diff --git a/pocs/zeroth-playground/examples/queue/page.mdx b/pocs/zeroth-playground/examples/queue/page.mdx deleted file mode 100644 index 2d14e8ec..00000000 --- a/pocs/zeroth-playground/examples/queue/page.mdx +++ /dev/null @@ -1,21 +0,0 @@ -import { CoinDraws } from "./components/coin-draws"; - -A transition at rate $\lambda$ fires within a step of length $\Delta t$ with probability $1 - e^{-\lambda \Delta t}$. The compiler turns that into a coin: an input draw `u_T` and the test `X(u_T) >= θ`, with $\theta = e^{-\lambda \Delta t}$ computed at compile time. - -Over a step of 0.5, `Arrive` at rate 2.5 gets $\theta = e^{-1.25} \approx 0.29$, so it fires in about 71% of steps: - -```python -return (Waiting + 1.0 <= 4.0) & (X(u_Arrive) >= 0.2865047968601901) -``` - -The first term is the capacity of `Waiting`, four tokens. The draws are Reals, so the places are Reals too and the modules use the theory LRA: `Waiting = Var(REAL)`, `Waiting + 1.0`. - -
    - -
    - -### Options - -- **Time step** sets $\Delta t$, and every threshold follows it. -- **Marking** on Int keeps the places Int and moves each draw into an LRA module, `Draw_Arrive`, that writes a Bool `hit_Arrive`. The guard reads `X(hit_Arrive)` in place of the draw. -- **Rates** on clock refuses the net with `clocks-capacity`, because of the cap on `Waiting`. diff --git a/pocs/zeroth-playground/examples/sir-model/meta.ts b/pocs/zeroth-playground/examples/sir-model/meta.ts deleted file mode 100644 index 360ce7b0..00000000 --- a/pocs/zeroth-playground/examples/sir-model/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "Weighted arcs", - title: "SIR model", - summary: "An output arc of weight 2: coins compile it, clocks refuse it.", - rung: "rates", - order: 80, - listed: true, - options: { shape: "modular" }, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/sir-model/net.pn.yaml b/pocs/zeroth-playground/examples/sir-model/net.pn.yaml deleted file mode 100644 index d98dfaaf..00000000 --- a/pocs/zeroth-playground/examples/sir-model/net.pn.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: sir_epidemic_model -description: 'An SIR epidemic with constant rates: infection makes a susceptible person infected, and recovery moves an infected person to recovered.' -kind: stochastic - -places: - Susceptible: - Infected: - Recovered: - -transitions: - Infection: - inputs: - Susceptible: - Infected: - outputs: - Infected: - weight: 2 - rate: 3 - Recovery: - inputs: - Infected: - outputs: - Recovered: - rate: 1 - -marking: - Susceptible: 9 - Infected: 1 diff --git a/pocs/zeroth-playground/examples/sir-model/page.mdx b/pocs/zeroth-playground/examples/sir-model/page.mdx deleted file mode 100644 index fbe6bcb3..00000000 --- a/pocs/zeroth-playground/examples/sir-model/page.mdx +++ /dev/null @@ -1,31 +0,0 @@ -`Infection` takes one `Susceptible` and one `Infected` and gives back two `Infected`: an output arc of weight 2. Coins compile any weight. Clocks refuse it, because an SPN event moves one token per arc. - -In the monolithic shape, `Infection` takes its inputs at once, and its two tokens land at the end of the step: - -```python -Infected = ite(fire_Infection, Infected - 1.0, Infected) -Infected = ite(fire_Infection, Infected + 2.0, Infected) -``` - -In the modular shape, `Place_Infected` nets the two into one line: - -```python -Infected = ite(X(fire_Infection), Infected + 1.0, Infected) -``` - -The rates are constants, $\beta = 3$ and $\gamma = 1$. So `Infection` fires at rate 3 whatever the marking, where mass action would use $\beta \cdot S \cdot I$. - -### Options - -- **Rates** on clock refuses the net with `clocks-arc-weight`. -- **Shape** on monolithic gives the take and the landing above. - -### Open questions - - - Can SPN take a rate that reads the marking, such as $\beta \cdot S \cdot I$ for mass action? `Exp` takes a constant today. Under coins such a rate is code, which this playground does not parse. - - - - How should SPN express an arc of weight 2: `(n + 1) + 1`, an `Inc` of `Inc`, or a count on `Inc` and `Dec`? - diff --git a/pocs/zeroth-playground/playground/README.md b/pocs/zeroth-playground/playground/README.md index 16488325..f1713885 100644 --- a/pocs/zeroth-playground/playground/README.md +++ b/pocs/zeroth-playground/playground/README.md @@ -116,7 +116,7 @@ pnpm build Check visible changes in a headless browser against the build: 1. `pnpm build`. -2. Write a throwaway Playwright script as `playground/scripts/.tmp.ts` that opens `file://.../dist/index.html#`, acts, and screenshots. `#compiler`, or `#compiler/queue`, opens the Compiler view. +2. Write a throwaway Playwright script as `playground/scripts/.tmp.ts` that opens `file://.../dist/index.html#`, acts, and screenshots. `#compiler`, or `#compiler/capacity`, opens the Compiler view. 3. Run it with `node playground/scripts/.tmp.ts`, read the screenshots, then delete the script. Node 24 runs TypeScript as is, as `pnpm screenshot` does. Notes: diff --git a/pocs/zeroth-playground/playground/app/document.test.ts b/pocs/zeroth-playground/playground/app/document.test.ts index 8a8249ea..3d021d3c 100644 --- a/pocs/zeroth-playground/playground/app/document.test.ts +++ b/pocs/zeroth-playground/playground/app/document.test.ts @@ -15,51 +15,51 @@ function example(id: string): Example { return found; } -const queue = example("queue"); +const birthDeath = example("birth-death"); describe("the document", () => { it("opens as the example, unchanged", () => { - // GIVEN the queue + // GIVEN Birth–death under coins // WHEN its document is opened - const document = documentOf(queue); + const document = documentOf(birthDeath); // THEN it holds the example's text and options, no edits, and counts as unchanged expect(document).toEqual({ - exampleId: "queue", - irText: queue.ir, - options: queue.options, + exampleId: "birth-death", + irText: birthDeath.ir, + options: birthDeath.options, module: { editing: false, texts: {} }, }); - expect(isChanged(document, queue)).toBe(false); + expect(isChanged(document, birthDeath)).toBe(false); }); it("drops the module edits on a new text, and keeps the document on the same text", () => { - // GIVEN the queue with a hand edit to net.py + // GIVEN Birth–death with a hand edit to net.py const edited = { - ...documentOf(queue), - module: editFile(toggleEditing(documentOf(queue).module), "net.py", "# edited", "# compiled"), + ...documentOf(birthDeath), + module: editFile(toggleEditing(documentOf(birthDeath).module), "net.py", "# edited", "# compiled"), }; // WHEN the same text, then a new one, comes in - const same = withIrText(edited, queue.ir); - const changed = withIrText(edited, `${queue.ir}\n`); + const same = withIrText(edited, birthDeath.ir); + const changed = withIrText(edited, `${birthDeath.ir}\n`); // THEN the same text changes nothing; a new one drops the edits, keeps the toggle, and counts as a change expect(same).toBe(edited); expect(changed.module).toEqual({ editing: true, texts: {} }); - expect(isChanged(edited, queue)).toBe(true); - expect(isChanged(changed, queue)).toBe(true); + expect(isChanged(edited, birthDeath)).toBe(true); + expect(isChanged(changed, birthDeath)).toBe(true); }); it("stores an option the panel changes, and counts the default back as unchanged", () => { - // GIVEN the queue, which opens modular - const parsed = parsePetriNetIr(queue.ir); + // GIVEN Birth–death, which opens modular + const parsed = parsePetriNetIr(birthDeath.ir); if (!parsed.ok) { - throw new Error("queue does not parse"); + throw new Error("birth-death does not parse"); } - const document = documentOf(queue); + const document = documentOf(birthDeath); // WHEN the shape goes to monolithic, then back to modular const monolithic = withOptionText(document, parsed.ir, "shape", "monolithic"); const back = withOptionText(monolithic, parsed.ir, "shape", "modular"); // THEN the first is a change and the second is not - expect(isChanged(monolithic, queue)).toBe(true); - expect(isChanged(back, queue)).toBe(false); + expect(isChanged(monolithic, birthDeath)).toBe(true); + expect(isChanged(back, birthDeath)).toBe(false); }); }); diff --git a/pocs/zeroth-playground/playground/app/route.test.ts b/pocs/zeroth-playground/playground/app/route.test.ts index 81711b50..8d9568ce 100644 --- a/pocs/zeroth-playground/playground/app/route.test.ts +++ b/pocs/zeroth-playground/playground/app/route.test.ts @@ -7,19 +7,19 @@ describe("routeOf", () => { it("opens the example a hash names in the Examples view", () => { // GIVEN the hash of an example // WHEN it is read - const route = routeOf("#queue"); + const route = routeOf("#birth-death"); // THEN the Examples view opens on that example expect(route.view).toBe("examples"); - expect(route.example.id).toBe("queue"); + expect(route.example.id).toBe("birth-death"); }); it("opens the Compiler view, on the example after the slash", () => { // GIVEN the Compiler view's hash, with and without an example // WHEN each is read - const withExample = routeOf("#compiler/queue"); + const withExample = routeOf("#compiler/birth-death"); const alone = routeOf("#compiler"); // THEN both open the Compiler view, the bare one on the first example - expect(withExample).toEqual({ view: "compiler", example: expect.objectContaining({ id: "queue" }) }); + expect(withExample).toEqual({ view: "compiler", example: expect.objectContaining({ id: "birth-death" }) }); expect(alone).toEqual({ view: "compiler", example: FIRST_EXAMPLE }); }); diff --git a/pocs/zeroth-playground/playground/app/route.ts b/pocs/zeroth-playground/playground/app/route.ts index 327cee4b..0b6f4739 100644 --- a/pocs/zeroth-playground/playground/app/route.ts +++ b/pocs/zeroth-playground/playground/app/route.ts @@ -7,8 +7,8 @@ export type View = "examples" | "compiler"; export type Route = { view: View; example: Example }; /** - * What a URL hash names, so a link opens the playground on it: `#queue` is - * an example, `#compiler` the Compiler view, `#compiler/queue` both. An + * What a URL hash names, so a link opens the playground on it: `#capacity` + * is an example, `#compiler` the Compiler view, `#compiler/capacity` both. An * example the catalog does not hold opens the first one. */ export function routeOf(hash: string): Route { diff --git a/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx b/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx index 4199b26c..4f1f44fe 100644 --- a/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx +++ b/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx @@ -33,7 +33,7 @@ Each option picks one strategy. The Compiler options panel holds them in four gr -The highlighted value is the compiler's default, from `OPTIONS`, the table that also gives what each option applies to. The examples open with their own options: the modular shape wherever it compiles the net, clocks for Fork under clocks, and monolithic for the coloured nets, which the modular shape refuses with `modular-coloured-not-lowered`. +The highlighted value is the compiler's default, from `OPTIONS`, the table that also gives what each option applies to. Each example opens with the options that show its idea. Cycle opens monolithic, the other plain and coin examples modular, and the clock examples under clocks. Bucket, a coloured net, opens monolithic, since the modular shape refuses it with `modular-coloured-not-lowered`. Coins test each rated transition once per **Time step** against a uniform draw, in LIA or LRA. Clocks arm an exponential clock per transition over continuous time, in SPN. So **Rates** on clock takes **Shape**, **Time step**, **Marking** and **Control** out of play. diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts index dd05ad18..04ec24c7 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts @@ -27,33 +27,33 @@ function inputOfText(irText: string, options: CompilerOptions = {}): SampleInput return { exampleTitle: "Edited", irText, options, compilation: compile(irText, { options }) }; } -const queue = inputOf("queue"); -const drones = inputOf("drones"); -const forkClocked = inputOf("fork-clocked"); +const birthDeath = inputOf("birth-death"); +const bucket = inputOf("bucket"); +const conflictClocked = inputOf("conflict-clocked"); describe("stageSample on a net that compiles", () => { it("marks every stage's sample ok", () => { - // GIVEN Queue as it opens, which compiles + // GIVEN Birth–death under coins as it opens, which compiles // THEN every stage's sample is ok for (const stage of STAGES) { - expect(stageSample(stage.id, queue).tone, stage.id).toBe("ok"); + expect(stageSample(stage.id, birthDeath).tone, stage.id).toBe("ok"); } }); it("quotes the IR and counts what parses", () => { - // GIVEN Queue as it opens + // GIVEN Birth–death under coins as it opens // WHEN the IR text is sampled - const text = stageSample("ir-text", queue); + const text = stageSample("ir-text", birthDeath); // THEN it counts the lines and quotes the first, and the parse counts the places and transitions - expect(text.headline).toBe(`${queue.irText.split("\n").length} lines of YAML.`); - expect(text.excerpt?.lines[0]).toEqual({ number: 1, text: "name: queue", lit: false }); - expect(stageSample("parse", queue).headline).toBe("Parsed: 2 places, 2 transitions."); + expect(text.headline).toBe(`${birthDeath.irText.split("\n").length} lines of YAML.`); + expect(text.excerpt?.lines[0]).toEqual({ number: 1, text: "name: birth_death", lit: false }); + expect(stageSample("parse", birthDeath).headline).toBe("Parsed: 1 place, 2 transitions."); }); it("names the options that apply and the ones the panel sets", () => { - // GIVEN Queue as it opens, with the modular shape and a time step of 0.5 + // GIVEN Birth–death under coins as it opens, with the modular shape and a time step of 0.5 // WHEN the options are sampled - const options = stageSample("options", queue); + const options = stageSample("options", birthDeath); // THEN the panel's two options are named, and Slots, which a plain net has no use for, is muted expect(options.headline).toContain("the panel sets Shape modular, Time step 0.5"); expect(options.rows.find((row) => row.label === "Time step")).toEqual({ label: "Time step", value: "0.5" }); @@ -61,22 +61,22 @@ describe("stageSample on a net that compiles", () => { }); it("reads the graph's language, theory and modules", () => { - // GIVEN Queue as it opens, and Queue under the monolithic shape + // GIVEN Birth–death under coins as it opens, and under the monolithic shape // WHEN the graph is sampled - const monolithic = inputOf("queue", { dt: 0.5 }); - // THEN the modular graph has four modules and the monolithic one a single root module - expect(stageSample("graph", queue).headline).toBe("A linear graph in LRA: 4 modules, 6 variables."); + const monolithic = inputOf("birth-death", { dt: 0.5 }); + // THEN the modular graph has three modules and the monolithic one a single root module + expect(stageSample("graph", birthDeath).headline).toBe("A linear graph in LRA: 3 modules, 5 variables."); const graph = stageSample("graph", monolithic); - expect(graph.headline).toBe("A linear graph in LRA: 1 module, 4 variables."); - expect(graph.rows).toContainEqual({ label: "modules", value: "Queue" }); - expect(graph.rows).toContainEqual({ label: "root", value: "one module, Queue" }); - expect(stageSample("code-parser", queue).headline).toContain("No code strings"); + expect(graph.headline).toBe("A linear graph in LRA: 1 module, 3 variables."); + expect(graph.rows).toContainEqual({ label: "modules", value: "BirthDeath" }); + expect(graph.rows).toContainEqual({ label: "root", value: "one module, BirthDeath" }); + expect(stageSample("code-parser", birthDeath).headline).toContain("No code strings"); }); it("lists the files with net.py first and quotes it", () => { - // GIVEN Queue as it opens + // GIVEN Birth–death under coins as it opens // WHEN the files are sampled - const files = stageSample("files", queue); + const files = stageSample("files", birthDeath); // THEN net.py comes first and the excerpt quotes it from its first line expect(files.rows[0]?.label).toBe("net.py"); expect(files.excerpt?.source).toBe("net.py"); @@ -84,14 +84,14 @@ describe("stageSample on a net that compiles", () => { }); it("picks a nested line for the provenance and lights matching Python for the hover", () => { - // GIVEN Queue as it opens + // GIVEN Birth–death under coins as it opens // WHEN the provenance and the hover are sampled - const provenance = stageSample("provenance", queue); - const hover = stageSample("hover", queue); - // THEN the provenance names the innermost of the ranges at its line, and the hover lights Arrive's lines + const provenance = stageSample("provenance", birthDeath); + const hover = stageSample("hover", birthDeath); + // THEN the provenance names the innermost of the ranges at its line, and the hover lights Birth's lines expect(provenance.headline).toMatch(/^net\.py line \d+: \d+ ranges hold it; the innermost is line/u); expect(provenance.rows.some((row) => row.label === "source")).toBe(true); - expect(hover.rows[0]?.value).toContain("transition Arrive"); + expect(hover.rows[0]?.value).toContain("transition Birth"); expect(hover.excerpt?.lines.length).toBeGreaterThan(0); expect(hover.excerpt?.lines.every((line) => line.lit)).toBe(true); expect(provenance.excerpt?.lines.filter((line) => line.lit)).toHaveLength(1); @@ -100,34 +100,28 @@ describe("stageSample on a net that compiles", () => { describe("stageSample on a refused net", () => { it("counts the code strings the absent parser leaves refused", () => { - // GIVEN Drones as it opens, a net with five code strings + // GIVEN Bucket as it opens, a net whose one rate is a code string // WHEN the code parser is sampled - const parser = stageSample("code-parser", drones); - // THEN it is refused, and lists each string by surface and IR path + const parser = stageSample("code-parser", bucket); + // THEN it is refused, and lists the string by surface and IR path expect(parser.tone).toBe("refused"); - expect(parser.headline).toBe("5 code strings and no parser: 5 code-not-parsed errors."); - expect(parser.rows.map((row) => `${row.label} ${row.value}`)).toEqual([ - "dynamics dynamics.Drain.code", - "lambda transitions.Launch.rate", - "kernel transitions.Launch.kernel", - "lambda transitions.Land.guard", - "kernel transitions.Land.kernel", - ]); + expect(parser.headline).toBe("1 code string and no parser: 1 code-not-parsed error."); + expect(parser.rows.map((row) => `${row.label} ${row.value}`)).toEqual(["lambda transitions.Take.rate"]); }); it("refuses at lower with the codes and lines, and leaves the later stages idle", () => { - // GIVEN Drones as it opens + // GIVEN Bucket as it opens // WHEN the lowering and the diagnostics are sampled - const lower = stageSample("lower", drones); - const diagnostics = stageSample("diagnostics", drones); - // THEN the lowering is refused with lined errors, the stages after it idle, and the diagnostics list five errors + const lower = stageSample("lower", bucket); + const diagnostics = stageSample("diagnostics", bucket); + // THEN the lowering is refused with a lined error, the stages after it idle, and the diagnostics list one error expect(lower.tone).toBe("refused"); expect(lower.rows[0]?.value).toMatch(/^code-not-parsed: /u); expect(lower.rows[0]?.label).toMatch(/^line \d+$/u); for (const stage of ["graph", "emit", "files"] as const) { - expect(stageSample(stage, drones).tone, stage).toBe("idle"); + expect(stageSample(stage, bucket).tone, stage).toBe("idle"); } - expect(diagnostics.headline).toBe("5 errors, 0 warnings."); + expect(diagnostics.headline).toBe("1 error, 0 warnings."); expect(diagnostics.tone).toBe("ok"); expect(diagnostics.rows.every((row) => row.error === true)).toBe(true); }); @@ -158,29 +152,29 @@ describe("stageSample on a refused net", () => { }); it("still traces the IR and quotes a provenance from it", () => { - // GIVEN Drones as it opens, which the lowering refuses + // GIVEN Bucket as it opens, which the lowering refuses // THEN the trace has no Python, the provenance quotes an IR line, and the hover has nothing to light - expect(stageSample("trace", drones).rows[1]).toMatchObject({ label: "Python", muted: true }); - expect(stageSample("provenance", drones).headline).toMatch(/^IR line \d+/u); - expect(stageSample("hover", drones).headline).toContain("no Python to light"); + expect(stageSample("trace", bucket).rows[1]).toMatchObject({ label: "Python", muted: true }); + expect(stageSample("provenance", bucket).headline).toMatch(/^IR line \d+/u); + expect(stageSample("hover", bucket).headline).toContain("no Python to light"); }); }); describe("stageSample under clocks", () => { it("reads an SPN graph with its hidden clocks", () => { - // GIVEN Fork under clocks as it opens + // GIVEN Conflict with rates as it opens, under clocks // WHEN the graph is sampled - const graph = stageSample("graph", forkClocked); + const graph = stageSample("graph", conflictClocked); // THEN it is an SPN graph that hides one clock per transition, emitted with next and flow expect(graph.headline).toBe("An SPN graph: 6 modules, 12 variables."); expect(graph.rows).toContainEqual({ label: "hidden", value: "clk_TakeLeft, clk_TakeRight, clk_Return" }); - expect(stageSample("emit", forkClocked).rows).toContainEqual({ label: "step method", value: "next and flow" }); + expect(stageSample("emit", conflictClocked).rows).toContainEqual({ label: "step method", value: "next and flow" }); }); it("lights each line once, written as runs, and says the excerpt is cut", () => { - // GIVEN Fork under clocks as it opens + // GIVEN Conflict with rates as it opens, under clocks // WHEN the hover is sampled - const hover = stageSample("hover", forkClocked); + const hover = stageSample("hover", conflictClocked); // THEN the lit lines are listed as runs, and the excerpt shows the first 8 of them, once each expect(hover.headline).toContain("lights 18 lines of net.py"); expect(hover.rows).toContainEqual({ label: "net.py", value: "lines 11, 15, 19, 23–34, 73, 85, 101" }); @@ -189,9 +183,9 @@ describe("stageSample under clocks", () => { }); it("greys the options clocks fix", () => { - // GIVEN Fork under clocks as it opens + // GIVEN Conflict with rates as it opens, under clocks // WHEN the options are sampled - const options = stageSample("options", forkClocked); + const options = stageSample("options", conflictClocked); // THEN Shape is muted and reads as not used, not as its default, and Rates reads clock expect(options.rows.find((row) => row.label === "Shape")).toEqual({ label: "Shape", diff --git a/pocs/zeroth-playground/playground/compiler-view/stages.ts b/pocs/zeroth-playground/playground/compiler-view/stages.ts index 85c9ba0e..54c3f775 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stages.ts +++ b/pocs/zeroth-playground/playground/compiler-view/stages.ts @@ -118,7 +118,7 @@ export const STAGES: readonly Stage[] = [ output: "`lower` (**Shape**, **Rates**, **Time step**, **Marking**, **Conflicts**, **Control**, **Slots**) and `emit` (**Files**)", details: [ "The panel groups them by what they decide: Structure, Rates, Choices, Coloured tokens. **Files** sits under **Shape**; **Time step** and **Marking** sit under **Rates**.", - "One table, `OPTIONS`, gives each option its values, its default, the nets it applies to and why it does not apply to a net. `resolveOptions` fills every option left out with its default, **Shape** on monolithic among them. Each example opens with its own options: the modular shape wherever it compiles the net, clocks for Fork under clocks, monolithic for the coloured nets.", + "One table, `OPTIONS`, gives each option its values, its default, the nets it applies to and why it does not apply to a net. `resolveOptions` fills every option left out with its default, **Shape** on monolithic among them. Each example opens with the options that show its idea: monolithic for Cycle and Bucket, modular for the other plain and coin examples, clocks for the clock examples.", "`optionStates` greys an option that has no effect on the net. Its control reads \"not used\", and the reason takes the place of its hint: no conflict, no coloured place, clock rates.", "A change keeps only the options that apply and differ from their default (`optionsForNet`). `compile` keeps the same ones, drops any other with an `option-not-applicable` warning, so in the playground none appears, and reports the options in force as `compilation.options`.", "**Rates** on clock fixes the composition: **Shape**, **Time step**, **Marking** and **Control** no longer apply.", @@ -139,7 +139,7 @@ export const STAGES: readonly Stage[] = [ "A parser that would read the strings is built on the TypeScript compiler, too large for one HTML file.", "Without it, each code string the lowering reads is refused with `code-not-parsed`, naming its surface: `lambda`, `kernel` or `dynamics`.", "A net without code strings never calls it.", - "Boiler, Drones and Bucket carry code and are refused. The refusal is part of what the playground shows.", + "Bucket carries code and is refused. The refusal is part of what the playground shows.", ], }, { @@ -280,7 +280,7 @@ export const STAGES: readonly Stage[] = [ input: "The provenance under the pointer", output: "Lit lines in each other editor, a lit node in the preview", details: [ - "Two ranges match when their sources name the same item, or, when neither has a source, when one IR path equals the other or contains it: `places.Waiting` matches `places.Waiting.capacity`.", + "Two ranges match when their sources name the same item, or, when neither has a source, when one IR path equals the other or contains it: `places.Buffer` matches `places.Buffer.capacity`.", "A range with a source never matches one without.", `The hover takes effect once the pointer has rested ${DWELL_MS} ms, so a sweep across lines lights nothing.`, "The editor a hover starts in lights none of its own lines; its card shows the provenance there.", diff --git a/pocs/zeroth-playground/playground/options/option-edits.test.ts b/pocs/zeroth-playground/playground/options/option-edits.test.ts index 7e112d55..0a89ec54 100644 --- a/pocs/zeroth-playground/playground/options/option-edits.test.ts +++ b/pocs/zeroth-playground/playground/options/option-edits.test.ts @@ -18,8 +18,8 @@ function opened(id: string): { ir: PetriNetIr; options: CompilerOptions } { describe("withOption", () => { it("stores only the options off their default that apply to the net", () => { - // GIVEN the queue, which opens modular with a step of 0.5 - const { ir, options } = opened("queue"); + // GIVEN Birth–death under coins, which opens modular with a step of 0.5 + const { ir, options } = opened("birth-death"); // WHEN one option at a time is changed // THEN the panel keeps what bears on the net, and a text that is not a number changes nothing expect(withOption(ir, options, "shape", "monolithic")).toEqual({ dt: 0.5 }); @@ -30,8 +30,8 @@ describe("withOption", () => { }); it("drops the options clock rates ignore", () => { - // GIVEN the fork under clocks, its conflicts left open - const { ir, options } = opened("fork-clocked"); + // GIVEN the conflict under clocks, its conflicts left open + const { ir, options } = opened("conflict-clocked"); // WHEN the shape is changed, then the rates // THEN the shape does not stick under clocks, and conflicts survive the move to coins expect(withOption(ir, options, "shape", "modular")).toEqual({ rates: "clock", conflicts: "nondet" }); @@ -39,8 +39,8 @@ describe("withOption", () => { }); it("ignores a value the option does not have", () => { - // GIVEN the queue - const { ir, options } = opened("queue"); + // GIVEN Birth–death under coins + const { ir, options } = opened("birth-death"); // WHEN its shape is set to a value it does not take // THEN the options stay as they were expect(withOption(ir, options, "shape", "round")).toEqual(options); diff --git a/pocs/zeroth-playground/playground/preview/net-graph.test.ts b/pocs/zeroth-playground/playground/preview/net-graph.test.ts index be70570e..978a4d08 100644 --- a/pocs/zeroth-playground/playground/preview/net-graph.test.ts +++ b/pocs/zeroth-playground/playground/preview/net-graph.test.ts @@ -17,37 +17,46 @@ function irOf(id: string): PetriNetIr { describe("netGraph", () => { it("reads places with their marking and transitions with their rates and arcs", () => { - // GIVEN the queue + // GIVEN Birth–death // WHEN its graph is read - const graph = netGraph(irOf("queue")); - // THEN the places come first with their counts and capacity, then the rated transitions, then the arcs + const graph = netGraph(irOf("birth-death")); + // THEN the place comes first with its count, then the rated transitions, then the arcs expect(graph.nodes).toEqual([ - { id: "place:Waiting", kind: "place", name: "Waiting", tokens: 0, capacity: 4 }, - { id: "place:Served", kind: "place", name: "Served", tokens: 0 }, - { id: "transition:Arrive", kind: "transition", name: "Arrive", rate: 2.5, guarded: false, controllable: false }, - { id: "transition:Serve", kind: "transition", name: "Serve", rate: 3, guarded: false, controllable: false }, + { id: "place:Population", kind: "place", name: "Population", tokens: 0 }, + { id: "transition:Birth", kind: "transition", name: "Birth", rate: 2, guarded: false, controllable: false }, + { id: "transition:Death", kind: "transition", name: "Death", rate: 1, guarded: false, controllable: false }, ]); expect(graph.edges.map((edge) => [edge.source, edge.target, edge.weight, edge.kind])).toEqual([ - ["transition:Arrive", "place:Waiting", 1, "standard"], - ["place:Waiting", "transition:Serve", 1, "standard"], - ["transition:Serve", "place:Served", 1, "standard"], + ["transition:Birth", "place:Population", 1, "standard"], + ["place:Population", "transition:Death", 1, "standard"], ]); }); - it("keeps arc kinds and weights, coloured places and code rates", () => { - // GIVEN the boiler, the conflict and the drones + it("keeps arc kinds and weights, capacities, colours, guards and code rates", () => { + // GIVEN the arcs, the conflict, the bucket and a transition with a guard + const guardedIr: PetriNetIr = { + name: "guarded", + kind: "plain", + places: { A: null }, + transitions: { Go: { inputs: { A: null }, guard: "return true;" } }, + }; // WHEN their graphs are read - const boiler = netGraph(irOf("boiler")); + const arcs = netGraph(irOf("arcs")); const conflict = netGraph(irOf("conflict")); - const drones = netGraph(irOf("drones")); - // THEN the boiler keeps its read arc, coloured tank and guard, the conflict its arc weight and - // controllable transition, and the drones a rate written as code - expect(boiler.edges[0]).toMatchObject({ place: "Tank", transition: "Alarm", kind: "read" }); - expect(boiler.nodes[0]).toMatchObject({ kind: "place", name: "Tank", tokens: 1, colour: "Vessel", capacity: 1 }); - expect(boiler.nodes[2]).toMatchObject({ kind: "transition", name: "Alarm", guarded: true }); - expect(conflict.edges[0]).toMatchObject({ place: "Pool", transition: "TakeLeft", weight: 2 }); + const bucket = netGraph(irOf("bucket")); + const guarded = netGraph(guardedIr); + // THEN the arcs keep their weight and kinds, the conflict its controllable transition, the + // bucket its capped coloured place and its rate written as code, and the guard is marked + expect(arcs.edges.map((edge) => [edge.place, edge.weight, edge.kind])).toEqual([ + ["Parts", 2, "standard"], + ["Power", 1, "read"], + ["Jam", 1, "inhibitor"], + ["Products", 1, "standard"], + ]); expect(conflict.nodes[3]).toMatchObject({ name: "TakeLeft", controllable: true }); - expect(drones.nodes[3]).toMatchObject({ name: "Launch", rate: "code" }); + expect(bucket.nodes[0]).toMatchObject({ kind: "place", name: "Pool", tokens: 2, colour: "Ball", capacity: 2 }); + expect(bucket.nodes[2]).toMatchObject({ name: "Take", rate: "code" }); + expect(guarded.nodes[1]).toMatchObject({ name: "Go", guarded: true }); }); it("drops an arc to a place the IR does not declare", () => { @@ -88,16 +97,16 @@ describe("layoutNet", () => { }); it("keys the cache on structure alone and reuses one promise per structure", () => { - // GIVEN the cycle, the cycle with another marking, and the fork + // GIVEN the cycle, the cycle with another marking, and the conflict const cycle = irOf("cycle"); const graph = netGraph(cycle); const remarked = netGraph({ ...cycle, marking: { A: 3 } }); - const fork = netGraph(irOf("fork")); + const conflict = netGraph(irOf("conflict")); // WHEN their layouts are looked up - // THEN the two cycles share a key and a promise, and the fork has a key of its own + // THEN the two cycles share a key and a promise, and the conflict has a key of its own expect(layoutKey(graph)).toBe(layoutKey(remarked)); expect(layoutFor(graph)).toBe(layoutFor(remarked)); - expect(layoutKey(fork)).not.toBe(layoutKey(graph)); + expect(layoutKey(conflict)).not.toBe(layoutKey(graph)); }); it("places a graph at a cached geometry with the graph's own marking", async () => { From d7f68bd90a2b31bbceec4556524567d0a244e569 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 2 Oct 2026 10:50:55 +0200 Subject: [PATCH 03/20] Give each component its own CSS over tokens and cascade layers theme/ now holds three files: tokens.css (colours, type sizes, spacing, radii, shadows and durations, and the layer order), base.css (the reset, one focus ring, a zero-specificity button reset, the shared mono capitals class and one reduced-motion block) and prose.css (the docs pages, the guide and the stage card). Every component imports its own file, written with native nesting. Monaco's CSS sits in a vendor layer through a small Vite plugin, so our rules win without extra specificity; only the important declarations that override Monaco's inline styles remain. A pixel diff of the main views and states shows no change apart from reduced motion, which now stops every transition, and the accent focus ring on a few scroll areas that showed the browser's. --- pocs/zeroth-playground/examples/README.md | 2 +- .../components/coin-clock-explorer.css | 95 +- .../birth-death/components/coin-draws.css | 28 +- .../components/clock-race.css | 20 +- .../examples/cycle/components/one-step.css | 26 +- pocs/zeroth-playground/playground/README.md | 20 +- pocs/zeroth-playground/playground/app/app.css | 9 + pocs/zeroth-playground/playground/app/app.tsx | 2 + .../playground/app/example-picker.css | 171 ++ .../playground/app/example-picker.tsx | 6 +- .../playground/app/header.css | 75 + .../playground/app/header.tsx | 4 +- .../playground/app/view-switch.css | 58 + .../playground/app/view-switch.tsx | 4 +- .../compiler-view/compiler-view.css | 752 --------- .../compiler-view/compiler-view.tsx | 6 +- .../compiler-view/pipeline-graph.css | 251 +++ .../compiler-view/pipeline-graph.tsx | 2 + .../playground/compiler-view/stage-card.css | 147 ++ .../playground/compiler-view/stage-card.tsx | 12 +- .../compiler-view/stage-card/sample-view.css | 134 ++ .../compiler-view/stage-card/sample-view.tsx | 6 +- .../compiler-view/stage-overview.css | 61 + .../compiler-view/stage-overview.tsx | 7 +- .../playground/compiler-view/stage-ref.css | 20 + .../playground/compiler-view/stage-ref.tsx | 2 + .../compiler-view/strategy-table.css | 36 + .../compiler-view/strategy-table.tsx | 2 + .../playground/docs/code-block.css | 33 +- .../playground/docs/documentation.css | 4 + .../playground/docs/documentation.tsx | 6 +- .../playground/docs/figures/figure.css | 17 +- .../playground/docs/open-question.css | 35 + .../playground/docs/open-question.tsx | 4 +- .../playground/editor/monaco-editor.css | 154 ++ .../playground/editor/monaco-editor.tsx | 4 +- .../playground/editor/swiss-theme.ts | 5 +- .../examples-view/compiler-options-panel.css | 106 ++ .../examples-view/compiler-options-panel.tsx | 4 +- .../examples-view/diagnostics-list.css | 52 + .../examples-view/diagnostics-list.tsx | 2 + .../examples-view/examples-view.css | 13 + .../examples-view/examples-view.tsx | 3 + .../playground/examples-view/file-list.css | 34 + .../playground/examples-view/file-list.tsx | 2 + .../playground/examples-view/module-view.css | 22 + .../playground/examples-view/module-view.tsx | 3 + pocs/zeroth-playground/playground/main.tsx | 7 +- .../playground/preview/net-preview.css | 98 ++ .../playground/preview/net-preview.tsx | 2 + .../playground/theme/base.css | 67 + .../playground/theme/prose.css | 138 ++ .../playground/theme/theme.css | 1489 ----------------- .../playground/theme/tokens.css | 78 + .../zeroth-playground/playground/ui/panel.css | 157 ++ .../zeroth-playground/playground/ui/panel.tsx | 6 +- .../playground/ui/resizable.css | 61 + .../playground/ui/switch.css | 65 + .../playground/ui/switch.tsx | 4 +- .../playground/ui/use-folds.ts | 2 +- pocs/zeroth-playground/vite.config.ts | 11 + 61 files changed, 2264 insertions(+), 2382 deletions(-) create mode 100644 pocs/zeroth-playground/playground/app/app.css create mode 100644 pocs/zeroth-playground/playground/app/example-picker.css create mode 100644 pocs/zeroth-playground/playground/app/header.css create mode 100644 pocs/zeroth-playground/playground/app/view-switch.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/compiler-view.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-overview.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-ref.css create mode 100644 pocs/zeroth-playground/playground/compiler-view/strategy-table.css create mode 100644 pocs/zeroth-playground/playground/docs/documentation.css create mode 100644 pocs/zeroth-playground/playground/docs/open-question.css create mode 100644 pocs/zeroth-playground/playground/editor/monaco-editor.css create mode 100644 pocs/zeroth-playground/playground/examples-view/compiler-options-panel.css create mode 100644 pocs/zeroth-playground/playground/examples-view/diagnostics-list.css create mode 100644 pocs/zeroth-playground/playground/examples-view/examples-view.css create mode 100644 pocs/zeroth-playground/playground/examples-view/file-list.css create mode 100644 pocs/zeroth-playground/playground/examples-view/module-view.css create mode 100644 pocs/zeroth-playground/playground/preview/net-preview.css create mode 100644 pocs/zeroth-playground/playground/theme/base.css create mode 100644 pocs/zeroth-playground/playground/theme/prose.css delete mode 100644 pocs/zeroth-playground/playground/theme/theme.css create mode 100644 pocs/zeroth-playground/playground/theme/tokens.css create mode 100644 pocs/zeroth-playground/playground/ui/panel.css create mode 100644 pocs/zeroth-playground/playground/ui/resizable.css create mode 100644 pocs/zeroth-playground/playground/ui/switch.css diff --git a/pocs/zeroth-playground/examples/README.md b/pocs/zeroth-playground/examples/README.md index 7427654e..f223920d 100644 --- a/pocs/zeroth-playground/examples/README.md +++ b/pocs/zeroth-playground/examples/README.md @@ -111,7 +111,7 @@ return X(u_Birth) >= 0.36787944117144233 ## Figures -Every React component of an example sits in its `components/` folder, with its CSS beside it, and imports nothing from `playground/`. A drawing is small, minimal and correct about the step it shows. +Every React component of an example sits in its `components/` folder, with its CSS beside it, and imports nothing from `playground/`. Its CSS uses the playground's tokens, such as `var(--accent)` or `var(--fs-xs)`, from `playground/theme/tokens.css`. A drawing is small, minimal and correct about the step it shows. - Text keeps a fixed size whatever the panel's width: the docs panel is about 200 px wide in a 1000 px window and 300 px in a 1440 px one. Lay a drawing out in HTML and CSS, or in an SVG without a `viewBox`, placed in percentages or drawn at the width a `ResizeObserver` measures. Never scale text with a `viewBox`. - `Figure` is a size container, so a drawing can change its layout with `@container` queries. diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css index fd0821be..81111e74 100644 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css +++ b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css @@ -5,9 +5,9 @@ flex-direction: column; gap: 0.6rem; padding: 0.75rem 0.8rem 0.8rem; - border: 1px solid var(--sw-hair-2); - border-radius: 2px; - background: var(--sw-paper-2); + border: 1px solid var(--hair-2); + border-radius: var(--r-xs); + background: var(--paper-2); } .explorer__field { @@ -21,13 +21,13 @@ justify-content: space-between; align-items: baseline; gap: 0.5rem; - color: var(--sw-ink); - font-size: 0.8rem; + color: var(--ink); + font-size: var(--fs-md); } .explorer__field-value { - color: var(--sw-accent); - font-family: var(--sw-mono); + color: var(--accent); + font-family: var(--mono); font-size: 0.75rem; font-variant-numeric: tabular-nums; } @@ -35,12 +35,12 @@ .explorer__range { width: 100%; margin: 0; - accent-color: var(--sw-accent); + accent-color: var(--accent); } .explorer__range:focus-visible, .explorer__button:focus-visible { - outline: 2px solid var(--sw-accent); + outline: 2px solid var(--accent); outline-offset: 2px; } @@ -55,54 +55,57 @@ .explorer__button { flex: none; padding: 0.3rem 0.7rem; - border: 1px solid var(--sw-accent); - border-radius: 2px; - background: var(--sw-accent); - color: var(--sw-paper-2); - font-family: var(--sw-mono); - font-size: 0.68rem; + border: 1px solid var(--accent); + border-radius: var(--r-xs); + background: var(--accent); + color: var(--paper-2); + font-family: var(--mono); + font-size: var(--fs-label); font-weight: 500; letter-spacing: 0.08em; + /* The font's own line height sets the button's height. */ + line-height: normal; text-transform: uppercase; - cursor: pointer; - transition: background-color 120ms var(--sw-ease), border-color 120ms var(--sw-ease); + transition: + background-color var(--fast) var(--ease), + border-color var(--fast) var(--ease); } .explorer__button:hover { - border-color: var(--sw-accent-deep); - background: var(--sw-accent-deep); + border-color: var(--accent-deep); + background: var(--accent-deep); } .explorer__verdict { flex: 1 1 13rem; min-height: 2.9em; padding-left: 0.55rem; - border-left: 2px solid var(--sw-hair); - color: var(--sw-ink-2); + border-left: 2px solid var(--hair); + color: var(--ink-2); font-size: 0.75rem; line-height: 1.45; } .explorer__verdict--fires { - border-left-color: var(--sw-accent); - color: var(--sw-ink); + border-left-color: var(--accent); + color: var(--ink); } .explorer__verdict--waits { - border-left-color: var(--sw-ink-2); - color: var(--sw-ink); + border-left-color: var(--ink-2); + color: var(--ink); } .explorer__rate { padding-top: 0.45rem; - border-top: 1px solid var(--sw-hair-2); - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.72rem; + border-top: 1px solid var(--hair-2); + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-xs); } .explorer__value { - color: var(--sw-ink); + color: var(--ink); font-weight: 600; } @@ -114,41 +117,41 @@ .survival-plot__text, .survival-plot__draw-label { - font-family: var(--sw-mono); - font-size: 11px; + font-family: var(--mono); + font-size: var(--fs-svg); } .survival-plot__text--muted { - fill: var(--sw-ink-2); + fill: var(--ink-2); } .survival-plot__text--accent { - fill: var(--sw-accent); + fill: var(--accent); } .survival-plot__band { - fill: var(--sw-accent-wash); + fill: var(--accent-wash); } .survival-plot__mark { - stroke: var(--sw-accent); + stroke: var(--accent); stroke-width: 3; } .survival-plot__axis { - stroke: var(--sw-ink-2); + stroke: var(--ink-2); stroke-width: 1.25; } .survival-plot__guide { - stroke: var(--sw-accent); + stroke: var(--accent); stroke-width: 1; stroke-dasharray: 3 3; } .survival-plot__curve { fill: none; - stroke: var(--sw-ink); + stroke: var(--ink); stroke-width: 1.5; stroke-linejoin: round; } @@ -162,26 +165,20 @@ } .survival-plot__draw--fires .survival-plot__draw-line { - stroke: var(--sw-accent); + stroke: var(--accent); } .survival-plot__draw--fires .survival-plot__draw-dot, .survival-plot__draw--fires .survival-plot__draw-label { - fill: var(--sw-accent); + fill: var(--accent); } .survival-plot__draw--waits .survival-plot__draw-line { - stroke: var(--sw-ink-2); + stroke: var(--ink-2); stroke-dasharray: 2 2; } .survival-plot__draw--waits .survival-plot__draw-dot, .survival-plot__draw--waits .survival-plot__draw-label { - fill: var(--sw-ink-2); -} - -@media (prefers-reduced-motion: reduce) { - .explorer__button { - transition: none; - } + fill: var(--ink-2); } diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css index 4c4ca967..63fddd69 100644 --- a/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css +++ b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css @@ -3,13 +3,13 @@ .coin-draws { --coin-step: 1.6s; - font-family: var(--sw-mono); + font-family: var(--mono); } /* The readout names the step, the draw and what it decides; one line at a time. */ .coin-draws__readout { display: grid; - font-size: 0.72rem; + font-size: var(--fs-xs); line-height: 1.4; } @@ -19,16 +19,16 @@ } .coin-draws__step--fires { - color: var(--sw-accent); + color: var(--accent); } .coin-draws__step--waits { - color: var(--sw-ink-2); + color: var(--ink-2); } .coin-draws__summary { display: none; - color: var(--sw-ink); + color: var(--ink); } .coin-draws__axis { @@ -39,36 +39,36 @@ .coin-draws__region, .coin-draws__tick { - fill: var(--sw-ink-2); - font-size: 11px; + fill: var(--ink-2); + font-size: var(--fs-svg); } .coin-draws__region--fires, .coin-draws__tick--threshold { - fill: var(--sw-accent); + fill: var(--accent); } .coin-draws__band { - fill: var(--sw-accent-wash); + fill: var(--accent-wash); } .coin-draws__line { - stroke: var(--sw-ink-2); + stroke: var(--ink-2); stroke-width: 1.25; } .coin-draws__threshold { - stroke: var(--sw-accent); + stroke: var(--accent); stroke-width: 1.5; } .coin-draws__draw--fires { - fill: var(--sw-accent); + fill: var(--accent); } .coin-draws__draw--waits { - fill: var(--sw-paper-2); - stroke: var(--sw-ink-2); + fill: var(--paper-2); + stroke: var(--ink-2); stroke-width: 1.5; } diff --git a/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css b/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css index 87fcd98a..8a1b0195 100644 --- a/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css +++ b/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css @@ -13,8 +13,8 @@ display: flex; flex-direction: column; gap: 0.55rem; - font-family: var(--sw-mono); - font-size: 0.72rem; + font-family: var(--mono); + font-size: var(--fs-xs); line-height: 1.3; } @@ -26,12 +26,12 @@ } .clock-race__name { - color: var(--sw-ink); + color: var(--ink); font-weight: 500; } .clock-race__fires { - color: var(--sw-accent); + color: var(--accent); font-size: 0.66rem; font-weight: 600; letter-spacing: 0.06em; @@ -46,21 +46,21 @@ } .clock-race__zero { - color: var(--sw-ink-2); + color: var(--ink-2); } .clock-race__rail { flex: 1; height: 12px; - border: 1px solid var(--sw-hair); - background: var(--sw-paper); + border: 1px solid var(--hair); + background: var(--paper); } /* The bar shrinks toward the 0 at its left, as its clock runs down. */ .clock-race__bar { display: block; height: 100%; - background: var(--sw-accent); + background: var(--accent); transform-origin: left center; animation: var(--race-loop) linear infinite; } @@ -139,7 +139,7 @@ width: 9px; height: 9px; border-radius: 50%; - background: var(--sw-accent); + background: var(--accent); animation: var(--race-loop) step-end infinite; } @@ -156,7 +156,7 @@ } .clock-race__wait { - color: var(--sw-ink-2); + color: var(--ink-2); visibility: hidden; animation: clock-race-wait var(--race-loop) step-end infinite; } diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step.css b/pocs/zeroth-playground/examples/cycle/components/one-step.css index 0c4fa800..b9e5330b 100644 --- a/pocs/zeroth-playground/examples/cycle/components/one-step.css +++ b/pocs/zeroth-playground/examples/cycle/components/one-step.css @@ -5,8 +5,8 @@ grid-template-columns: 1fr auto 1fr; align-items: center; gap: 0.5rem; - font-family: var(--sw-mono); - font-size: 0.72rem; + font-family: var(--mono); + font-size: var(--fs-xs); line-height: 1.3; } @@ -16,13 +16,13 @@ align-items: center; gap: 0.45rem; padding: 0.5rem 0.4rem 0.55rem; - border: 1px solid var(--sw-hair-2); - border-radius: 2px; - background: var(--sw-paper-2); + border: 1px solid var(--hair-2); + border-radius: var(--r-xs); + background: var(--paper-2); } .one-step__label { - color: var(--sw-ink-2); + color: var(--ink-2); } .one-step__places { @@ -42,20 +42,20 @@ place-items: center; width: 26px; height: 26px; - border: 1.25px solid var(--sw-ink); + border: 1.25px solid var(--ink); border-radius: 50%; - background: var(--sw-paper-2); + background: var(--paper-2); } .one-step__token { width: 9px; height: 9px; border-radius: 50%; - background: var(--sw-accent); + background: var(--accent); } .one-step__name { - color: var(--sw-ink); + color: var(--ink); font-weight: 500; } @@ -68,17 +68,17 @@ } .one-step__fires { - color: var(--sw-accent); + color: var(--accent); font-weight: 600; } .one-step__waits { - color: var(--sw-ink-2); + color: var(--ink-2); } .one-step__arrow::before { content: "→"; - color: var(--sw-ink-2); + color: var(--ink-2); font-size: 1rem; line-height: 1; } diff --git a/pocs/zeroth-playground/playground/README.md b/pocs/zeroth-playground/playground/README.md index f1713885..1cfcf725 100644 --- a/pocs/zeroth-playground/playground/README.md +++ b/pocs/zeroth-playground/playground/README.md @@ -19,7 +19,7 @@ The source sits in three folders at the root. `vite.config.ts` sets Vite's root | `preview/` | the net graph, its elkjs layout, the SVG | | `docs/` | the documentation view, the page registry, and the components it passes to every page: the `Figure` frame, `OpenQuestion`, and `CodeBlock` for fenced code | | `pointer/` | the hover dwell shared by both views | -| `theme/` | the swiss variables and styles, fonts, the wordmark | +| `theme/` | the tokens, the base and prose layers, fonts, the wordmark | | `scripts/` | the screenshot script | `index.html` and `main.tsx` sit at the top of the folder. @@ -82,9 +82,25 @@ The panel stores only what `optionsForNet` keeps: the options that apply to the - Every React Compiler diagnostic fails `pnpm build`, the dev server's module and the tests that import it: a ref read in render, `setState` in render, an effect that only derives state, a construct the compiler cannot memoize. `vite.config.ts` sets `panicThreshold: "all_errors"` and the stricter checks. Restructure the component rather than opting out. `"use no memo"` still compiles the function, and a diagnostic inside it is only logged. - Effects only synchronize systems React does not own. Today that is Monaco, in `editor/monaco-editor.tsx`. - State is plain data with pure transition functions beside it, tested without the DOM. `app/document.ts`, `examples-view/module-edits.ts` and `pointer/pointer-motion.ts` are examples. -- Styles are plain CSS with the swiss variables in `theme/theme.css`. Use current CSS where it removes code: anchor positioning, `@starting-style`, `:has`, the Popover API. - Comments say what the code does and why. Prose follows the house style: plain words, short sentences, no em dashes. +## Styles + +Styles are plain CSS. A component imports its own file, placed beside it, and nests its rules with native CSS nesting. The views that lay out resizable groups import `ui/resizable.css`, which holds the classes those groups share. + +| File | Holds | +| --- | --- | +| `theme/tokens.css` | the cascade layers, and the tokens: colours, type sizes, spacing, radii, shadows, durations | +| `theme/base.css` | the reset and the page, one focus ring, a button reset with no specificity, `.caps` for mono capitals, `.note` | +| `theme/prose.css` | `.docs`, the reading column of the pages and the guide, and `.prose`, the typography the stage card shares with them | + +- `main.tsx` imports the three before anything else, because `tokens.css` names the layers before any rule uses one. +- The layers, lowest first, are `base`, `prose` and `vendor`. A plugin in `vite.config.ts` puts Monaco's own CSS in `vendor`. A component's rules sit in no layer, so they win over all three without a longer selector. +- Use a token where the value is one. A value only one rule uses stays a literal in that rule. `editor/swiss-theme.ts` repeats the colours as hex, because Monaco's theme API takes literals. +- `!important` only overrides a style Monaco writes inline. Monaco's list also writes rules at run time, outside any layer, and those win over Monaco's layered ones. `editor/monaco-editor.css` restores the one that showed. +- Reduced motion zeroes the duration tokens. A figure that runs its own keyframes shows a still frame instead. +- Use current CSS where it removes code: anchor positioning, `@starting-style`, `:has`, the Popover API. + ## Bundle `pnpm build` inlines everything into `dist/index.html`: scripts, CSS, fonts and the Monaco editor worker. MDX maths is rendered to SVG at build time. Monaco and elkjs are most of the file. Measure the file before adding a dependency. diff --git a/pocs/zeroth-playground/playground/app/app.css b/pocs/zeroth-playground/playground/app/app.css new file mode 100644 index 00000000..2732d6f5 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/app.css @@ -0,0 +1,9 @@ +.app { + display: grid; + grid-template-rows: var(--header-height) minmax(0, 1fr); + height: 100%; +} + +.workspace { + min-height: 0; +} diff --git a/pocs/zeroth-playground/playground/app/app.tsx b/pocs/zeroth-playground/playground/app/app.tsx index 88230c0e..787cc112 100644 --- a/pocs/zeroth-playground/playground/app/app.tsx +++ b/pocs/zeroth-playground/playground/app/app.tsx @@ -11,6 +11,8 @@ import { type View, routeOf } from "./route"; import type { Hover } from "../examples-view/hover"; +import "./app.css"; + /** * The playground: the header over the two views. It holds the open document * and compiles it once per render for both views; each view is hidden, not diff --git a/pocs/zeroth-playground/playground/app/example-picker.css b/pocs/zeroth-playground/playground/app/example-picker.css new file mode 100644 index 00000000..cc9b609d --- /dev/null +++ b/pocs/zeroth-playground/playground/app/example-picker.css @@ -0,0 +1,171 @@ +/* The trigger centres its label and chevron; inside the label, the feature and the net share a baseline. */ +.picker__trigger { + anchor-name: --example-picker; + display: inline-flex; + align-items: center; + gap: 0.55rem; + max-width: 24rem; + height: 1.75rem; + padding: 0 0.6rem 0 var(--s-3); + border: 1px solid var(--hair); + border-radius: var(--r-sm); + background: var(--paper-2); + font-size: var(--fs-md); + line-height: 1; + transition: border-color var(--fast) var(--ease); + + &:hover { + border-color: var(--ink-2); + } +} + +.picker__label { + display: inline-flex; + align-items: baseline; + gap: 0.55rem; + min-width: 0; +} + +.picker__feature { + overflow: hidden; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; +} + +.picker__net { + flex: 0 0 auto; + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-label); +} + +.picker__chevron { + flex: 0 0 auto; + align-self: center; + width: 0.4rem; + height: 0.4rem; + margin: 0 0.15rem 0 0.1rem; + border-right: 1.25px solid var(--ink-2); + border-bottom: 1.25px solid var(--ink-2); + /* Rotated, the two borders draw a V whose ink sits low in its box: lift it to the centre. */ + transform: translateY(-0.1rem) rotate(45deg); + transition: transform var(--base) var(--ease); + + .picker__trigger:has(+ .picker__list:popover-open) & { + transform: translateY(0.1rem) rotate(-135deg); + } +} + +/* The list sits under the button, right-aligned to it, and fades in. The footer closes it at the bottom. */ +.picker__list { + position-anchor: --example-picker; + inset: auto; + top: anchor(bottom); + right: anchor(right); + width: min(20rem, calc(100vw - 2rem)); + max-height: min(80vh, 32rem); + margin: 0.35rem 0 0; + padding: var(--s-1) var(--s-1) 0; + border: 1px solid var(--hair); + border-radius: var(--r-md); + background: var(--paper-2); + /* A popover takes the system's text colour unless told otherwise. */ + color: var(--ink); + box-shadow: var(--shadow-pop); + overflow-y: auto; + opacity: 0; + translate: 0 -0.25rem; + transition: + opacity var(--fast) var(--ease), + translate var(--base) var(--ease), + overlay var(--base) allow-discrete, + display var(--base) allow-discrete; + + &:popover-open { + opacity: 1; + translate: 0 0; + + @starting-style { + opacity: 0; + translate: 0 -0.25rem; + } + } +} + +/* A rung's title recedes behind its examples: small capitals in a lighter grey, in line with the features. */ +.picker__group-title { + padding: 0.6rem 0.6rem 0.2rem 1.6rem; + color: var(--ink-3); + font-size: var(--fs-label-sm); +} + +.picker__option { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + align-items: center; + gap: var(--s-1); + width: 100%; + height: 1.875rem; + padding: 0 0.6rem 0 0.35rem; + border-radius: 0.5rem; + font-size: 0.82rem; + font-weight: 500; + text-align: left; + transition: background-color var(--fast) var(--ease); + + /* The pointer greys a row and the keyboard washes it in the accent, so the two never look alike. */ + &:hover { + background: var(--hair-2); + } + + &:focus-visible { + outline: none; + background: var(--accent-wash); + } + + /* The open example: a check and a heavier weight, never a fill, so hover and focus still show on it. */ + &[aria-selected="true"] { + font-weight: 600; + } + + & .picker__feature { + font-weight: inherit; + } +} + +/* Two borders of a box turned a quarter back draw the check, as two borders turned forward draw the chevron. */ +.picker__check { + justify-self: center; + width: 0.5rem; + height: 0.28rem; + border-bottom: 1.5px solid var(--accent); + border-left: 1.5px solid var(--accent); + rotate: -45deg; + translate: 0 -0.08rem; + opacity: 0; + + .picker__option[aria-selected="true"] & { + opacity: 1; + } +} + +/* The net and the summary of the example under the pointer, else under the keyboard focus. */ +.picker__about { + position: sticky; + bottom: 0; + min-height: 4.4rem; + margin: var(--s-1) -0.25rem 0; + padding: 0.55rem 0.95rem 0.6rem; + border-top: 1px solid var(--hair-2); + background: var(--paper-2); + color: var(--ink-2); + font-size: 0.76rem; +} + +.picker__about-net { + display: block; + margin-bottom: 0.15rem; + color: var(--ink); + font-size: var(--fs-label-sm); +} diff --git a/pocs/zeroth-playground/playground/app/example-picker.tsx b/pocs/zeroth-playground/playground/app/example-picker.tsx index 208a23b1..9484f36c 100644 --- a/pocs/zeroth-playground/playground/app/example-picker.tsx +++ b/pocs/zeroth-playground/playground/app/example-picker.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { EXAMPLES, LADDER, exampleById } from "../../examples/catalog"; +import "./example-picker.css"; + type ExamplePickerProps = { exampleId: string; onSelect: (id: string) => void; @@ -102,7 +104,7 @@ export const ExamplePicker: React.FC = ({ exampleId, onSelec const titleId = `${LIST_ID}-${rung.id}`; return examples.length === 0 ? null : (
    -
    +
    {rung.title}
    {examples.map((example) => ( @@ -132,7 +134,7 @@ export const ExamplePicker: React.FC = ({ exampleId, onSelec ); })}
    diff --git a/pocs/zeroth-playground/playground/app/header.css b/pocs/zeroth-playground/playground/app/header.css new file mode 100644 index 00000000..ace47c62 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/header.css @@ -0,0 +1,75 @@ +.header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--s-5); + padding: 0 var(--s-5); + border-bottom: 1px solid var(--hair); +} + +.header__wordmark { + display: block; + width: auto; + height: 1.1rem; + color: var(--ink-2); + opacity: 0.55; +} + +.header__title { + margin: 0; + overflow: hidden; + font-size: var(--fs-xs); + letter-spacing: 0.09em; + white-space: nowrap; + text-overflow: ellipsis; + + /* The joining word stays lowercase between the two names. */ + & i { + margin: 0 0.1em; + color: color-mix(in oklab, var(--ink-2) 55%, var(--paper)); + font-style: normal; + font-weight: 400; + letter-spacing: 0.02em; + text-transform: none; + } +} + +.header__example { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.header__reset { + display: inline-flex; + align-items: center; + gap: 0.4rem; + height: 1.75rem; + padding: 0 0.65rem; + border: 1px solid var(--hair); + border-radius: var(--r-sm); + background: var(--paper-2); + color: var(--ink-2); + font-size: var(--fs-sm); + animation: header-reset-in var(--base) var(--ease); + transition: + color var(--fast) var(--ease), + border-color var(--fast) var(--ease); + + &:hover { + border-color: var(--ink-2); + color: var(--ink); + } +} + +.header__reset-icon { + width: 0.75rem; + height: 0.75rem; +} + +@keyframes header-reset-in { + from { + opacity: 0; + transform: translateX(0.25rem); + } +} diff --git a/pocs/zeroth-playground/playground/app/header.tsx b/pocs/zeroth-playground/playground/app/header.tsx index 0c340122..c2463bd8 100644 --- a/pocs/zeroth-playground/playground/app/header.tsx +++ b/pocs/zeroth-playground/playground/app/header.tsx @@ -4,6 +4,8 @@ import { ViewSwitch } from "./view-switch"; import type { View } from "./route"; +import "./header.css"; + type HeaderProps = { view: View; onSelectView: (view: View) => void; @@ -35,7 +37,7 @@ export const Header: React.FC = ({ return (
    -

    +

    Petri net to Zeroth reactive modules

    diff --git a/pocs/zeroth-playground/playground/app/view-switch.css b/pocs/zeroth-playground/playground/app/view-switch.css new file mode 100644 index 00000000..3adbf452 --- /dev/null +++ b/pocs/zeroth-playground/playground/app/view-switch.css @@ -0,0 +1,58 @@ +/* + * The two views as a rounded track, the one shown inked by a pill. The pill + * is the track's ::before, anchored to the pressed button, so it glides to + * whichever view is chosen. The track, the pill and the buttons share one + * height and one radius: a squircle where the browser draws one. + */ +.header__views { + --segment-height: 1.75rem; + --segment-radius: 0.7rem; + position: relative; + display: inline-flex; + justify-self: end; + border-radius: var(--segment-radius); + corner-shape: squircle; + background: var(--paper-2); + box-shadow: var(--shadow-lift); + isolation: isolate; + + @supports (corner-shape: squircle) { + --segment-radius: calc(var(--segment-height) / 2); + } + + &::before { + content: ""; + position: absolute; + position-anchor: --current-view; + top: anchor(top); + right: anchor(right); + bottom: anchor(bottom); + left: anchor(left); + z-index: -1; + border-radius: var(--segment-radius); + corner-shape: squircle; + background: color-mix(in oklab, var(--ink) 70%, var(--paper-2)); + transition: + left var(--slow) var(--ease), + right var(--slow) var(--ease); + } +} + +.header__view { + height: var(--segment-height); + padding: 0 1.05rem; + border-radius: var(--segment-radius); + corner-shape: squircle; + color: var(--ink-3); + line-height: normal; + transition: color var(--slow) var(--ease); + + &:hover { + color: var(--ink-2); + } + + &[aria-pressed="true"] { + anchor-name: --current-view; + color: var(--paper-2); + } +} diff --git a/pocs/zeroth-playground/playground/app/view-switch.tsx b/pocs/zeroth-playground/playground/app/view-switch.tsx index 38b56296..47da3b58 100644 --- a/pocs/zeroth-playground/playground/app/view-switch.tsx +++ b/pocs/zeroth-playground/playground/app/view-switch.tsx @@ -1,5 +1,7 @@ import type { View } from "./route"; +import "./view-switch.css"; + const VIEWS: readonly { id: View; label: string }[] = [ { id: "examples", label: "Examples" }, { id: "compiler", label: "Compiler" }, @@ -18,7 +20,7 @@ export const ViewSwitch: React.FC = ({ view, onSelect }) => { )} -

    +

    {KIND_LABELS[stage.kind]} · {OWNER_LABELS[stage.owner]}

    @@ -63,11 +65,11 @@ export const StageCard: React.FC = ({ stage, sample, exampleTitl

    {stage.call}
    -
    {from}
    +
    {from}
    -
    {to}
    +
    {to}
    diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css new file mode 100644 index 00000000..0ed08a7d --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css @@ -0,0 +1,134 @@ +/* What the stage made of the example, beside the card: a headline, values, and a few quoted lines. */ +.sample { + align-self: start; + min-width: 0; + padding-left: var(--s-5); + border-left: 1px solid var(--hair-2); + + &[data-tone="ok"] .sample__dot { + background: var(--accent); + } + + &[data-tone="refused"] .sample__dot { + background: var(--red); + } + + &[data-tone="idle"] { + & .sample__dot { + border: 1px solid var(--ink-2); + background: none; + } + + & .sample__headline { + color: var(--ink-2); + } + } +} + +.sample__headline { + display: flex; + align-items: baseline; + gap: var(--s-2); + margin: 0 0 var(--s-3); + color: var(--ink); + font-size: 0.85rem; + font-weight: 500; + line-height: 1.5; +} + +.sample__dot { + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--ink-2); + transform: translateY(-0.05rem); +} + +.sample__rows { + display: grid; + grid-template-columns: minmax(4.5rem, max-content) minmax(0, 1fr); + margin: 0 0 0.85rem; + font-size: var(--fs-sm); + line-height: 1.45; +} + +.sample__row { + display: contents; + + & :is(dt, dd) { + padding: 0.28rem 0; + border-top: 1px solid var(--hair-2); + } + + & dt { + padding-right: 0.9rem; + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-code); + white-space: nowrap; + } + + & dd { + margin: 0; + color: var(--ink); + font-family: var(--mono); + font-size: var(--fs-xs); + overflow-wrap: anywhere; + } + + &[data-muted="true"] :is(dt, dd) { + color: var(--ink-2); + opacity: 0.6; + } + + &[data-error="true"] dd { + color: var(--red); + } +} + +.sample__excerpt { + margin: 0; + + & pre { + margin: 0; + padding: 0.4rem 0; + border: 1px solid var(--hair-2); + background: var(--paper-2); + font-family: var(--mono); + font-size: var(--fs-code); + font-variant-ligatures: none; + line-height: 1.6; + overflow-x: auto; + } +} + +.sample__source { + margin-bottom: 0.3rem; + font-size: var(--fs-label-sm); + font-weight: 400; +} + +.sample__line { + display: block; + padding-right: var(--s-3); + white-space: pre; + + &[data-lit="true"] { + background: var(--accent-wash); + } + + /* Lines quoted out of order: a hairline marks where the numbers skip. */ + &[data-skipped="true"] { + border-top: 1px dashed var(--hair); + } +} + +.sample__number { + display: inline-block; + width: 2.6rem; + padding-right: 0.8rem; + color: var(--ink-2); + text-align: right; + opacity: 0.7; +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx index 1a558bb3..0fee5b93 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx +++ b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx @@ -1,5 +1,7 @@ import type { StageSample } from "../stage-sample"; +import "./sample-view.css"; + type SampleViewProps = { exampleTitle: string; sample: StageSample; @@ -10,7 +12,7 @@ export const SampleView: React.FC = ({ exampleTitle, sample }) const { excerpt } = sample; return (
    -

    Live · {exampleTitle}

    +

    Live · {exampleTitle}

    -
    {excerpt.source}
    +
    {excerpt.source}
                 {excerpt.lines.map((line, index) => {
                   const previous = excerpt.lines[index - 1];
    diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-overview.css b/pocs/zeroth-playground/playground/compiler-view/stage-overview.css
    new file mode 100644
    index 00000000..36750716
    --- /dev/null
    +++ b/pocs/zeroth-playground/playground/compiler-view/stage-overview.css
    @@ -0,0 +1,61 @@
    +/* The stage panel with no stage focused: every stage in pipeline order, one line each. */
    +.stage-overview {
    +  padding: var(--s-4) 1.35rem var(--s-6);
    +}
    +
    +.stage-overview__intro {
    +  max-width: 60ch;
    +}
    +
    +.stage-overview__list {
    +  display: grid;
    +  grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr));
    +  column-gap: 2.25rem;
    +  margin: 0;
    +  padding: 0;
    +  list-style: none;
    +}
    +
    +.stage-overview__row {
    +  display: grid;
    +  grid-template-columns: 1.6rem 9.5rem minmax(0, 1fr);
    +  align-items: baseline;
    +  gap: var(--s-2);
    +  width: 100%;
    +  padding: 0.38rem 0;
    +  border-top: 1px solid var(--hair-2);
    +  font-size: var(--fs-md);
    +  line-height: 1.45;
    +  text-align: left;
    +
    +  &:hover .stage-overview__name {
    +    color: var(--accent);
    +  }
    +
    +  &:focus-visible {
    +    outline-offset: -1px;
    +  }
    +
    +  &[data-tone="refused"] .stage-overview__headline {
    +    color: var(--red);
    +  }
    +
    +  &[data-tone="idle"] .stage-overview__headline {
    +    color: var(--ink-2);
    +  }
    +}
    +
    +.stage-overview__number {
    +  color: var(--ink-2);
    +  font-family: var(--mono);
    +  font-size: 0.66rem;
    +}
    +
    +.stage-overview__name {
    +  font-weight: 600;
    +  transition: color var(--fast) var(--ease);
    +}
    +
    +.stage-overview__headline {
    +  color: var(--text-2);
    +}
    diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx
    index 7daca14a..ad34b60d 100644
    --- a/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx
    +++ b/pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx
    @@ -6,6 +6,9 @@ import { useStageFocus } from "./stage-focus";
     import type { FocusHandoff } from "./focus-handoff";
     import type { StageWithSample } from "./stage-sample";
     
    +import "./stage-card.css";
    +import "./stage-overview.css";
    +
     type StageOverviewProps = {
       exampleTitle: string;
       /** Every stage in pipeline order, with what it made of the example. */
    @@ -22,8 +25,8 @@ export const StageOverview: React.FC = ({ exampleTitle, samp
       const { togglePin } = useStageFocus();
       return (
         
    -
    -

    Overview

    +
    +

    Overview

    {exampleTitle}, stage by stage

    Hover a stage in the graph or in the guide to open its card here; click to pin it. diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-ref.css b/pocs/zeroth-playground/playground/compiler-view/stage-ref.css new file mode 100644 index 00000000..182572b4 --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/stage-ref.css @@ -0,0 +1,20 @@ +/* A stage named in the prose: a dotted underline that turns blue with the stage in the graph. */ +.stage-ref { + border-bottom: 1px dotted var(--ink-2); + transition: + color var(--fast) var(--ease), + border-color var(--fast) var(--ease), + background-color var(--fast) var(--ease); + + &:hover, + &[data-focused="true"] { + border-bottom-color: var(--accent); + background: var(--accent-wash); + color: var(--accent); + } + + &[aria-pressed="true"] { + border-bottom-style: solid; + border-bottom-color: var(--accent); + } +} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx index 6de5b4bd..f1a7be27 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx +++ b/pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx @@ -3,6 +3,8 @@ import { useStageTarget } from "./stage-focus"; import type { StageId } from "./stages"; +import "./stage-ref.css"; + type StageRefProps = { stage: StageId; children: React.ReactNode; diff --git a/pocs/zeroth-playground/playground/compiler-view/strategy-table.css b/pocs/zeroth-playground/playground/compiler-view/strategy-table.css new file mode 100644 index 00000000..3f7d00ea --- /dev/null +++ b/pocs/zeroth-playground/playground/compiler-view/strategy-table.css @@ -0,0 +1,36 @@ +/* The options in the guide: wider than the reading column, each value as code, the default on a grey chip. */ +.strategy-table { + max-width: none; + font-size: var(--fs-md); + + & :is(th, td) { + vertical-align: baseline; + } + + & thead th { + font-size: var(--fs-label-sm); + } + + & tbody th { + width: auto; + color: var(--ink); + font-family: var(--sans); + font-size: var(--fs-md); + font-weight: 600; + letter-spacing: normal; + white-space: nowrap; + } + + & td:nth-child(2) { + white-space: nowrap; + } + + & code + code { + margin-left: 0.3rem; + } + + & code[data-default="false"] { + background: none; + color: var(--ink-2); + } +} diff --git a/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx b/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx index 11186d72..c848e63b 100644 --- a/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx +++ b/pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx @@ -6,6 +6,8 @@ import type { OptionName } from "../../compiler"; import type { Example } from "../../examples/catalog"; import type { OptionLabels } from "../options/option-labels"; +import "./strategy-table.css"; + /** By option, then by value, the titles of the examples that open with it. */ type Opened = Map>; diff --git a/pocs/zeroth-playground/playground/docs/code-block.css b/pocs/zeroth-playground/playground/docs/code-block.css index de45fd5d..3b0110ba 100644 --- a/pocs/zeroth-playground/playground/docs/code-block.css +++ b/pocs/zeroth-playground/playground/docs/code-block.css @@ -1,20 +1,19 @@ -/* -- Code a page quotes: one row per line, a long line wrapping under a hanging indent -- */ - -.docs .code-block { - margin: 0.2rem 0 1rem; +/* Code a page quotes: one row per line, a long line wrapping under a hanging indent. */ +.code-block { + margin: 0.2rem 0 var(--s-4); padding: 0.55rem 0.7rem; - border-left: 2px solid var(--sw-hair); - border-radius: 0 2px 2px 0; - background: var(--sw-paper); + border-left: 2px solid var(--hair); + border-radius: 0 var(--r-xs) var(--r-xs) 0; + background: var(--paper); line-height: 1.45; -} -.docs .code-block code { - display: block; - padding: 0; - background: none; - font-size: 0.74rem; - white-space: normal; + & code { + display: block; + padding: 0; + background: none; + font-size: 0.74rem; + white-space: normal; + } } /* The continuation of a wrapped line starts 4ch in, so it never reads as a line of its own. */ @@ -24,8 +23,8 @@ text-indent: -4ch; white-space: pre-wrap; overflow-wrap: anywhere; -} -.code-block__line + .code-block__line { - margin-top: 0.15rem; + & + & { + margin-top: 0.15rem; + } } diff --git a/pocs/zeroth-playground/playground/docs/documentation.css b/pocs/zeroth-playground/playground/docs/documentation.css new file mode 100644 index 00000000..f1052927 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/documentation.css @@ -0,0 +1,4 @@ +/* The net a page uses, as a label over its title. */ +.docs__net { + margin: 0 0 0.3rem; +} diff --git a/pocs/zeroth-playground/playground/docs/documentation.tsx b/pocs/zeroth-playground/playground/docs/documentation.tsx index 651a60a7..6a5791d2 100644 --- a/pocs/zeroth-playground/playground/docs/documentation.tsx +++ b/pocs/zeroth-playground/playground/docs/documentation.tsx @@ -2,6 +2,8 @@ import { exampleById } from "../../examples/catalog"; import { EXAMPLE_PAGES } from "./example-pages"; import { PAGE_COMPONENTS } from "./page-components"; +import "./documentation.css"; + /** * The example's page: the net it uses and the feature it tackles, from its * metadata, over the page itself. @@ -10,10 +12,10 @@ export const Documentation: React.FC<{ exampleId: string }> = ({ exampleId }) => const example = exampleById(exampleId); const Page = EXAMPLE_PAGES[exampleId]; return ( -

    +
    {example === undefined ? null : ( <> -

    {example.title}

    +

    {example.title}

    {example.feature}

    )} diff --git a/pocs/zeroth-playground/playground/docs/figures/figure.css b/pocs/zeroth-playground/playground/docs/figures/figure.css index 61737990..a29580a3 100644 --- a/pocs/zeroth-playground/playground/docs/figures/figure.css +++ b/pocs/zeroth-playground/playground/docs/figures/figure.css @@ -1,16 +1,19 @@ -/* -- The figure frame every page shares: the drawing, then its caption ----- */ - -/* Each drawing sets its own CSS beside it, in its example's components/ folder. - The frame is a size container, so a drawing can change its layout with the - width it gets: the docs panel is about 200 px wide in a 1000 px window. */ +/* + * The frame every figure on a page shares: the drawing, then its caption. + * Each drawing sets its own CSS beside it, in its example's components/ + * folder. The frame is a size container, so a drawing can change its layout + * with the width it gets: the docs panel is about 200 px wide in a 1000 px + * window. + */ .figure { - margin: 0.75rem 0 1rem; + max-width: 22rem; + margin: 1.1rem 0 var(--s-5); container-type: inline-size; } .figure__caption { margin-top: 0.6rem; - color: var(--sw-ink-2); + color: var(--ink-2); font-size: 0.75rem; line-height: 1.45; } diff --git a/pocs/zeroth-playground/playground/docs/open-question.css b/pocs/zeroth-playground/playground/docs/open-question.css new file mode 100644 index 00000000..1f2bc0d6 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/open-question.css @@ -0,0 +1,35 @@ +/* Open questions close every page: amber boxes, each naming who can answer it. */ +.open-question { + margin: 0 0 var(--s-3); + padding: 0.6rem 0.85rem 0.7rem; + border-left: 2px solid var(--amber-line); + border-radius: 0 var(--r-xs) var(--r-xs) 0; + background: var(--amber-wash); + + & code { + background: var(--veil); + } +} + +.open-question__owner { + display: block; + margin-bottom: 0.3rem; + color: var(--amber); + font-size: var(--fs-label-sm); + font-weight: 600; + line-height: 1.2; +} + +.open-question__body { + color: var(--text-2); + font-size: 0.84rem; + line-height: 1.55; + + & p { + margin: 0; + } + + & p + p { + margin-top: var(--s-2); + } +} diff --git a/pocs/zeroth-playground/playground/docs/open-question.tsx b/pocs/zeroth-playground/playground/docs/open-question.tsx index 632bde9c..f86e0982 100644 --- a/pocs/zeroth-playground/playground/docs/open-question.tsx +++ b/pocs/zeroth-playground/playground/docs/open-question.tsx @@ -1,3 +1,5 @@ +import "./open-question.css"; + type OpenQuestionProps = { /** Who can settle it. */ owner: "HASH" | "Zeroth" | "HASH and Zeroth"; @@ -9,7 +11,7 @@ type OpenQuestionProps = { export const OpenQuestion: React.FC = ({ owner, children }) => { return ( ); diff --git a/pocs/zeroth-playground/playground/editor/monaco-editor.css b/pocs/zeroth-playground/playground/editor/monaco-editor.css new file mode 100644 index 00000000..1f4648f2 --- /dev/null +++ b/pocs/zeroth-playground/playground/editor/monaco-editor.css @@ -0,0 +1,154 @@ +.monaco-host { + position: absolute; + inset: 0; + user-select: text; +} + +/* + * Expanded chevrons in the gutter rest at a low opacity instead of Monaco's + * zero, and come up while the pointer is over the gutter. Collapsed ones stay + * at full, as Monaco has them. + */ +.margin-view-overlays { + & :is(.codicon-folding-expanded, .codicon-folding-manual-expanded) { + opacity: 0.3; + } + + &:hover :is(.codicon-folding-expanded, .codicon-folding-manual-expanded) { + opacity: 1; + } +} + +.monaco-editor { + & .monaco-hover, + & .suggest-widget { + font-family: var(--sans); + } + + /* Completions and their details panel: slightly rounded, barely lifted. */ + & .suggest-widget, + & .suggest-details { + overflow: hidden; + border-radius: 0.35rem; + box-shadow: var(--shadow-menu); + } + + /* + * "No suggestions." and "Loading...": a quiet note rather than a row. + * Monaco sets the widget's height inline to one row; only !important wins + * over that, so the widget takes the message's height. + */ + & .suggest-widget.message { + height: auto !important; + } + + & .suggest-widget > .message { + padding-block: 3px; + color: color-mix(in oklab, var(--ink-2) 65%, var(--paper-2)); + font-size: 12px; + } + + /* Each row's description at full strength instead of Monaco's 70%. */ + & .suggest-widget .details-label { + opacity: 1; + } + + /* + * Monaco's list writes a focused row's icon colour at run time, outside any + * layer, and so wins over the suggest widget's own rule in `vendor`. This is + * that rule again, with its specificity, which wins over the list's. + */ + & .suggest-widget .monaco-list .monaco-list-row.focused .codicon { + color: var(--vscode-editorSuggestWidget-selectedIconForeground); + } + + /* + * Monaco sets the details panel's height inline from a one-line + * measurement, which cuts off a description that wraps. The descriptions + * are a line or two, so the panel takes its content's height, capped; only + * !important wins over the inline height. + */ + & .suggest-details-container, + & .suggest-details, + & .suggest-details > .monaco-scrollable-element, + & .suggest-details .body { + height: auto !important; + max-height: 12rem; + } + + /* The panel's text clear of the border, with the close button beside its first line. */ + & .suggest-details .docs { + padding: 7px 32px 8px 10px; + color: var(--ink); + font-family: var(--sans); + font-size: 12.5px; + line-height: 1.45; + } + + & .suggest-details .codicon-close { + top: 8px; + right: 8px; + color: var(--ink-2); + } + + /* Hover cards: paper-grey text on ink, roomier, with the mono face for code. */ + & .monaco-hover { + --monaco-monospace-font: var(--mono); + color: var(--hair); + + /* Monaco writes the editor's font size and line height inline here; only !important wins over them. */ + & .monaco-hover-content { + font-size: 13px !important; + line-height: 1.45 !important; + } + + & .hover-contents:not(.html-hover-contents) { + padding: 8px 12px; + } + + & strong { + color: var(--paper-2); + } + + & code { + color: var(--paper); + font-family: var(--mono); + font-size: 12px; + } + } +} + +/* Whole lines lit by a hover in another view, or flagged by a diagnostic. */ +.line-lit { + background: var(--accent-wash); + animation: line-lit-in var(--fast) var(--ease); +} + +@keyframes line-lit-in { + from { + background-color: transparent; + } +} + +.line-error { + background: var(--red-wash); +} + +.line-warning { + background: var(--ink-wash); +} + +/* Monaco writes the mark's width inline; only !important wins over it. */ +.line-error-mark, +.line-warning-mark { + width: 2px !important; + margin-left: 2px; +} + +.line-error-mark { + background: var(--red); +} + +.line-warning-mark { + background: var(--ink-2); +} diff --git a/pocs/zeroth-playground/playground/editor/monaco-editor.tsx b/pocs/zeroth-playground/playground/editor/monaco-editor.tsx index 1b0ab156..fa502e9f 100644 --- a/pocs/zeroth-playground/playground/editor/monaco-editor.tsx +++ b/pocs/zeroth-playground/playground/editor/monaco-editor.tsx @@ -9,6 +9,8 @@ import type { Trace } from "../../compiler"; import type { editor } from "monaco-editor/editor/editor.api.js"; import type { PETRI_NET_IR_LANGUAGE } from "./petri-net-ir-language"; +import "./monaco-editor.css"; + /** The editor instance a parent can reach through `editorRef`, to reveal a line say. */ export type CodeEditor = editor.IStandaloneCodeEditor; @@ -44,7 +46,7 @@ const OPTIONS: editor.IStandaloneEditorConstructionOptions = { lineDecorationsWidth: 6, glyphMargin: false, folding: true, - // Expanded chevrons rest faint and come up while the pointer is over the gutter; see theme.css. + // Expanded chevrons rest faint and come up while the pointer is over the gutter; see monaco-editor.css. showFoldingControls: "mouseover", wordBasedSuggestions: "off", quickSuggestions: { other: true, comments: false, strings: true }, diff --git a/pocs/zeroth-playground/playground/editor/swiss-theme.ts b/pocs/zeroth-playground/playground/editor/swiss-theme.ts index ab4e5dcd..93178068 100644 --- a/pocs/zeroth-playground/playground/editor/swiss-theme.ts +++ b/pocs/zeroth-playground/playground/editor/swiss-theme.ts @@ -2,7 +2,10 @@ import type { Monaco } from "./monaco"; export const SWISS_THEME = "swiss"; -/** `vs` in the swiss palette: ink on paper, the accent for keywords and numbers, hairlines for the gutter. */ +/** + * `vs` in the swiss palette: ink on paper, the accent for keywords and numbers, hairlines for the gutter. + * Monaco's theme takes colour literals, not CSS variables, so the palette of `theme/tokens.css` is hex here. + */ export function defineSwissTheme(monaco: Monaco): void { monaco.editor.defineTheme(SWISS_THEME, { base: "vs", diff --git a/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.css b/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.css new file mode 100644 index 00000000..5ea0e5f2 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.css @@ -0,0 +1,106 @@ +/* Sections of options, each with what it does; a dependent option nests under its parent. */ +.options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr)); + align-content: start; + gap: var(--s-5) 1.75rem; + height: 100%; + padding: 0.85rem 0.85rem var(--s-5); + overflow-y: auto; +} + +.options__section { + display: flex; + flex-direction: column; + gap: 0.65rem; + min-width: 0; +} + +.options__title { + margin: 0; + font-size: 0.66rem; + font-weight: 600; +} + +.options__about { + margin: -0.35rem 0 0.1rem; + color: var(--ink-2); + font-size: 0.74rem; + line-height: 1.35; +} + +.option { + display: grid; + grid-template-columns: 5.5rem minmax(0, 1fr); + align-items: center; + gap: var(--s-1) 0.6rem; + + /* An option that only matters through the one above hangs from it on a hairline. */ + &[data-nested="true"] { + margin-left: 0.55rem; + padding-left: 0.7rem; + border-left: 1px solid var(--hair); + } + + /* Off for this net: label and control fade, and the hint says why. */ + &[data-disabled="true"] { + & :is(.option__label, .option__control) { + opacity: 0.45; + } + + & .option__hint { + font-style: italic; + } + } +} + +.option__label { + color: var(--ink); + font-size: var(--fs-md); + font-weight: 500; +} + +.option__control { + width: 100%; + height: 1.6rem; + padding: 0 var(--s-2); + border: 1px solid var(--hair); + border-radius: var(--r-xs); + background: var(--paper-2); + color: var(--ink); + font-family: var(--mono); + font-size: var(--fs-xs); + transition: border-color var(--fast) var(--ease); + + &:hover:not(:disabled) { + border-color: var(--ink-2); + } + + &:focus-visible { + outline: none; + border-color: var(--accent); + } + + /* Placed and weighted to win over hover and focus, which a field being typed in has. */ + &[aria-invalid="true"]:not(:disabled) { + border-color: var(--red); + } + + &:disabled { + cursor: not-allowed; + } +} + +.option__control--select { + appearance: none; + padding-right: var(--s-6); + background: var(--chevron-down) no-repeat right var(--s-2) center / 0.55rem auto var(--paper-2); + cursor: pointer; +} + +.option__hint { + grid-column: 1 / -1; + margin: 0; + color: var(--ink-2); + font-size: var(--fs-xs); +} diff --git a/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx b/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx index c650b4ad..80caef9f 100644 --- a/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx +++ b/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx @@ -4,6 +4,8 @@ import { NOT_USED, OPTION_SECTIONS, type OptionLabels } from "../options/option- import type { OptionName, OptionState } from "../../compiler"; +import "./compiler-options-panel.css"; + function sectionId(title: string): string { return `options-${title.toLowerCase().replaceAll(" ", "-")}`; } @@ -86,7 +88,7 @@ export const CompilerOptionsPanel: React.FC = ({ stat
    {OPTION_SECTIONS.map((section) => (
    -

    +

    {section.title}

    {section.about}

    diff --git a/pocs/zeroth-playground/playground/examples-view/diagnostics-list.css b/pocs/zeroth-playground/playground/examples-view/diagnostics-list.css new file mode 100644 index 00000000..311935ef --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/diagnostics-list.css @@ -0,0 +1,52 @@ +.diagnostics { + flex: 0 0 auto; + max-height: 30%; + margin: 0; + padding: 0.35rem 0; + border-top: 1px solid var(--hair-2); + background: var(--paper); + list-style: none; + overflow-y: auto; +} + +.diagnostic { + display: flex; + align-items: baseline; + gap: var(--s-2); + padding: 0.15rem var(--s-3); + font-size: var(--fs-sm); + line-height: 1.35; +} + +.diagnostic__code { + flex: 0 0 auto; + color: var(--red); + font-family: var(--mono); + font-size: var(--fs-code); + + .diagnostic[data-severity="warning"] & { + color: var(--ink-2); + } +} + +.diagnostic__message { + flex: 1 1 auto; + min-width: 0; +} + +.diagnostic__item { + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-code); + font-style: normal; +} + +.diagnostic__line { + flex: 0 0 auto; + color: var(--accent); + font-size: var(--fs-xs); + + &:hover { + text-decoration: underline; + } +} diff --git a/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx b/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx index 85d697c2..74fb8ba4 100644 --- a/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx +++ b/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx @@ -1,5 +1,7 @@ import type { Diagnostic } from "../../compiler"; +import "./diagnostics-list.css"; + type DiagnosticsListProps = { errors: readonly Diagnostic[]; warnings: readonly Diagnostic[]; diff --git a/pocs/zeroth-playground/playground/examples-view/examples-view.css b/pocs/zeroth-playground/playground/examples-view/examples-view.css new file mode 100644 index 00000000..31e9a904 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/examples-view.css @@ -0,0 +1,13 @@ +/* The grid's display and templates are inline; the cells only need to clip. */ +.workspace__grid [data-cell] { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* While a toggle collapses or opens a track, the grid eases its templates; never under a drag. */ +.workspace__grid--easing { + transition: + grid-template-columns var(--fold) var(--ease), + grid-template-rows var(--fold) var(--ease); +} diff --git a/pocs/zeroth-playground/playground/examples-view/examples-view.tsx b/pocs/zeroth-playground/playground/examples-view/examples-view.tsx index 10a25f59..8989def9 100644 --- a/pocs/zeroth-playground/playground/examples-view/examples-view.tsx +++ b/pocs/zeroth-playground/playground/examples-view/examples-view.tsx @@ -28,6 +28,9 @@ import type { Compilation, Diagnostic } from "../../compiler"; import type { Document } from "../app/document"; import type { LineDecoration } from "../editor/line-decorations"; +import "../ui/resizable.css"; +import "./examples-view.css"; + type ExamplesViewProps = { document: Document; /** The document compiled, once per render, by the app. */ diff --git a/pocs/zeroth-playground/playground/examples-view/file-list.css b/pocs/zeroth-playground/playground/examples-view/file-list.css new file mode 100644 index 00000000..1d334ad8 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/file-list.css @@ -0,0 +1,34 @@ +.file-list { + display: flex; + flex-direction: column; + height: 100%; + padding: 0.4rem 0; + overflow-y: auto; +} + +.file-list__item { + flex: 0 0 auto; + overflow: hidden; + padding: 0.3rem var(--s-3); + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-code); + /* The font's own line height sets the row's height. */ + line-height: normal; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + transition: + color var(--fast) var(--ease), + background-color var(--fast) var(--ease); + + &:hover { + color: var(--ink); + } + + &[aria-current="true"] { + background: var(--paper); + color: var(--ink); + box-shadow: inset 2px 0 0 var(--accent); + } +} diff --git a/pocs/zeroth-playground/playground/examples-view/file-list.tsx b/pocs/zeroth-playground/playground/examples-view/file-list.tsx index 3084e00f..5239cfbe 100644 --- a/pocs/zeroth-playground/playground/examples-view/file-list.tsx +++ b/pocs/zeroth-playground/playground/examples-view/file-list.tsx @@ -1,3 +1,5 @@ +import "./file-list.css"; + type FileListProps = { paths: readonly string[]; selected: string; diff --git a/pocs/zeroth-playground/playground/examples-view/module-view.css b/pocs/zeroth-playground/playground/examples-view/module-view.css new file mode 100644 index 00000000..93b44ca1 --- /dev/null +++ b/pocs/zeroth-playground/playground/examples-view/module-view.css @@ -0,0 +1,22 @@ +/* The emitted module: the editor, the files beside it, the diagnostics under both. */ +.module { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.module__main { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +/* A positioned box the editor stretches over. */ +.fill { + position: relative; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} diff --git a/pocs/zeroth-playground/playground/examples-view/module-view.tsx b/pocs/zeroth-playground/playground/examples-view/module-view.tsx index 6a9bf3bc..81e758d2 100644 --- a/pocs/zeroth-playground/playground/examples-view/module-view.tsx +++ b/pocs/zeroth-playground/playground/examples-view/module-view.tsx @@ -12,6 +12,9 @@ import { useFolds } from "../ui/use-folds"; import type { Compilation } from "../../compiler"; +import "../ui/resizable.css"; +import "./module-view.css"; + type ModuleViewProps = { /** Names the editor's models, so undo never reaches another example's text. */ exampleId: string; diff --git a/pocs/zeroth-playground/playground/main.tsx b/pocs/zeroth-playground/playground/main.tsx index b610239d..68184596 100644 --- a/pocs/zeroth-playground/playground/main.tsx +++ b/pocs/zeroth-playground/playground/main.tsx @@ -1,9 +1,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { App } from "./app/app"; +// The theme first: tokens.css names the cascade layers, which must come before any layered rule. +import "./theme/tokens.css"; +import "./theme/base.css"; +import "./theme/prose.css"; import "./theme/fonts"; -import "./theme/theme.css"; +import { App } from "./app/app"; const root = document.getElementById("root"); if (root === null) { diff --git a/pocs/zeroth-playground/playground/preview/net-preview.css b/pocs/zeroth-playground/playground/preview/net-preview.css new file mode 100644 index 00000000..d33551b5 --- /dev/null +++ b/pocs/zeroth-playground/playground/preview/net-preview.css @@ -0,0 +1,98 @@ +/* The net in ink on paper; the item a hover lights, and its arcs, in the accent. */ +.net-frame { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + padding: var(--s-3); + overflow: hidden; +} + +.net { + display: block; + width: 100%; + height: 100%; + font-family: var(--mono); +} + +.net__node { + cursor: default; +} + +.net__shape { + fill: var(--paper-2); + stroke: var(--ink); + stroke-width: 1.25; + transition: + fill var(--base) var(--ease), + stroke var(--base) var(--ease), + stroke-width var(--base) var(--ease); +} + +.net__tokens circle { + fill: var(--ink); + transition: fill var(--base) var(--ease); +} + +.net__count { + fill: var(--ink); + font-size: 13px; + font-weight: 600; +} + +.net__label { + fill: var(--ink); + font-size: var(--fs-svg); + font-weight: 500; + transition: fill var(--base) var(--ease); +} + +.net__detail, +.net__weight { + fill: var(--ink-2); + font-size: var(--fs-svg-note); + letter-spacing: 0.02em; +} + +.net__weight { + paint-order: stroke fill; + stroke: var(--paper-2); + stroke-width: 3px; +} + +.net__line { + fill: none; + stroke: var(--ink-2); + stroke-width: 1.25; + transition: + stroke var(--base) var(--ease), + stroke-width var(--base) var(--ease); +} + +.net__inhibit { + fill: var(--paper-2); + stroke: context-stroke; + stroke-width: 1.2; +} + +.net__arc[data-kind="read"] .net__line { + stroke-dasharray: 4 3; +} + +.net__node[data-lit="true"] { + & .net__shape { + fill: var(--accent-wash); + stroke: var(--accent); + stroke-width: 2; + } + + & .net__tokens circle, + & .net__label { + fill: var(--accent); + } +} + +.net__arc[data-lit="true"] .net__line { + stroke: var(--accent); + stroke-width: 1.75; +} diff --git a/pocs/zeroth-playground/playground/preview/net-preview.tsx b/pocs/zeroth-playground/playground/preview/net-preview.tsx index 1c3c908e..30324075 100644 --- a/pocs/zeroth-playground/playground/preview/net-preview.tsx +++ b/pocs/zeroth-playground/playground/preview/net-preview.tsx @@ -5,6 +5,8 @@ import { Drawing } from "./net-preview/drawing"; import type { NetItem, PetriNetIr } from "../../compiler"; +import "./net-preview.css"; + type NetPreviewProps = { ir: PetriNetIr; /** The net item lit by a hover in any view. */ diff --git a/pocs/zeroth-playground/playground/theme/base.css b/pocs/zeroth-playground/playground/theme/base.css new file mode 100644 index 00000000..8d1a9fbe --- /dev/null +++ b/pocs/zeroth-playground/playground/theme/base.css @@ -0,0 +1,67 @@ +/* The reset and the page, and what every component starts from. */ +@layer base { + *, + *::before, + *::after { + box-sizing: border-box; + } + + html, + body, + #root { + height: 100%; + margin: 0; + } + + /* The interface is not text to copy; the documentation and the editors are. */ + body { + user-select: none; + background: var(--paper); + color: var(--ink); + font-family: var(--sans); + font-size: var(--fs-base); + line-height: 1.4; + -webkit-font-smoothing: antialiased; + } + + :focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; + } + + /* No specificity, so a component's own rules win without trying. */ + :where(button) { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + + /* Mono capitals for labels and titles; a page's h3 and table heads, which MDX writes without a class, take them too. */ + .caps, + .prose :is(h3, th) { + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-label); + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .note { + padding: var(--s-3); + color: var(--ink-2); + font-size: 0.85rem; + } + + @media (prefers-reduced-motion: reduce) { + :root { + --fast: 0s; + --base: 0s; + --slow: 0s; + --fold: 0s; + } + } +} diff --git a/pocs/zeroth-playground/playground/theme/prose.css b/pocs/zeroth-playground/playground/theme/prose.css new file mode 100644 index 00000000..e3020140 --- /dev/null +++ b/pocs/zeroth-playground/playground/theme/prose.css @@ -0,0 +1,138 @@ +/* + * The reading column of the pages and the guide, and the prose they share + * with the stage card. A component's rules, outside the layer, win over these. + */ +@layer prose { + .docs { + height: 100%; + padding: 1.1rem 1.35rem 2rem; + overflow-y: auto; + color: var(--text); + font-size: var(--fs-base); + line-height: 1.62; + user-select: text; + + & > * { + max-width: 60ch; + } + } + + .prose { + & h2 { + margin: 0 0 0.9rem; + color: var(--ink); + font-size: var(--fs-title); + font-weight: 600; + letter-spacing: -0.015em; + line-height: 1.25; + } + + & h3 { + margin: var(--s-6) 0 0.4rem; + } + + & p { + margin: 0 0 0.95rem; + + &:last-child { + margin-bottom: 0; + } + } + + & ul { + margin: 0 0 0.95rem; + padding-left: 1.1rem; + } + + & ol { + margin: 0 0 0.95rem; + padding-left: 1.35rem; + } + + & li { + margin: 0 0 0.35rem; + + &::marker { + color: var(--ink-2); + } + } + + & ol li { + margin-bottom: 0.45rem; + padding-left: 0.15rem; + + &::marker { + font-family: var(--mono); + font-size: 0.75rem; + } + } + + & strong { + color: var(--ink); + font-weight: 600; + } + + /* Code is quoted as written: no ligature turns != or >= into a symbol the source does not have. */ + & code { + padding: 0.05em 0.3em; + border-radius: var(--r-xs); + background: var(--paper); + color: var(--ink); + font-family: var(--mono); + font-size: 0.8em; + font-variant-ligatures: none; + white-space: nowrap; + } + + /* Math: MathJax SVG at the text's size; a display formula gets a line of its own. */ + & mjx-container { + font-size: 1.02em; + + &:not([display="true"]) svg { + vertical-align: -0.22em; + } + + &[display="true"] { + display: block; + max-width: 100%; + margin: 0.85rem 0 1.05rem; + padding: var(--s-1) 0; + overflow-x: auto; + overflow-y: hidden; + text-align: center; + } + } + + /* Tables: hairline rows, a mono header, cells that wrap inside the column. */ + & table { + width: 100%; + margin: 0.4rem 0 1.1rem; + border-collapse: collapse; + font-size: var(--fs-sm); + line-height: 1.45; + } + + & :is(th, td) { + padding: 0.4rem 0.6rem 0.4rem 0; + border-bottom: 1px solid var(--hair-2); + text-align: left; + vertical-align: top; + overflow-wrap: break-word; + } + + & th { + border-bottom-color: var(--hair); + } + + & tbody th { + width: 22%; + border-bottom-color: var(--hair-2); + letter-spacing: 0; + text-transform: none; + } + + & td code { + white-space: normal; + } + } +} diff --git a/pocs/zeroth-playground/playground/theme/theme.css b/pocs/zeroth-playground/playground/theme/theme.css deleted file mode 100644 index 59e87734..00000000 --- a/pocs/zeroth-playground/playground/theme/theme.css +++ /dev/null @@ -1,1489 +0,0 @@ -/* - * The swiss theme: paper, ink, one accent, hairlines, Inter and JetBrains - * Mono. - */ -:root { - --sw-paper: #edeff1; - --sw-paper-2: #f7f8f9; - --sw-ink: #0f1115; - --sw-ink-2: #565b66; - --sw-hair: #c3c8cf; - --sw-hair-2: #dde1e6; - --sw-accent: #0b5fff; - --sw-accent-deep: #0a3f9e; - --sw-accent-wash: rgba(11, 95, 255, 0.1); - --sw-cyan: #0a8fe0; - --sw-cyan-overlay: rgba(0, 168, 255, 0.22); - --sw-red: #d6402b; - --sw-red-wash: rgba(214, 64, 43, 0.12); - /* Open questions: an amber that reads as unresolved, not as an error. */ - --sw-question: #9a5b00; - --sw-question-wash: #fbf1df; - --sw-question-line: #e0a948; - --sw-sans: "Inter Variable", "Helvetica Neue", Helvetica, Arial, sans-serif; - --sw-mono: - "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - --sw-ease: cubic-bezier(0.2, 0.8, 0.2, 1); - --header-height: 3rem; - --panel-head: 2rem; - /* How long a fold eases; `useFolds` waits as long before it stops easing the layout. */ - --fold-duration: 260ms; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html, -body, -#root { - height: 100%; - margin: 0; -} - -/* The interface is not text to copy; the documentation and the editors are. */ -body { - user-select: none; - -webkit-user-select: none; - background: var(--sw-paper); - color: var(--sw-ink); - font-family: var(--sw-sans); - font-size: 14px; - line-height: 1.4; - -webkit-font-smoothing: antialiased; -} - -/* -- App frame ----------------------------------------------------------- */ - -.app { - display: grid; - grid-template-rows: var(--header-height) minmax(0, 1fr); - height: 100%; -} - -.header { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; - align-items: center; - gap: 1.25rem; - padding: 0 1.25rem; - border-bottom: 1px solid var(--sw-hair); -} - -.header__wordmark { - display: block; - height: 1.1rem; - width: auto; - color: var(--sw-ink-2); - opacity: 0.55; -} - -.header__title { - margin: 0; - overflow: hidden; - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.72rem; - font-weight: 500; - letter-spacing: 0.09em; - text-transform: uppercase; - white-space: nowrap; - text-overflow: ellipsis; -} - -/* The joining word stays lowercase between the two names. */ -.header__title i { - margin: 0 0.1em; - color: color-mix(in oklab, var(--sw-ink-2) 55%, var(--sw-paper)); - font-style: normal; - font-weight: 400; - letter-spacing: 0.02em; - text-transform: none; -} - -/* - * The view switch: a rounded track with the current view inked by a pill inset - * by the padding. The pill is the track's ::before, anchored to the pressed - * button, so it glides to whichever view is chosen; its radius nests inside - * the track's: the outer radius less the padding and the border. - */ -/* - * The track, the pill and the buttons share one height and one radius: a - * squircle where the browser draws one. The pill and the labels sit a step - * softer than ink, on a white track lifted by a faint shadow. - */ -.header__views { - --segment-height: 1.75rem; - --segment-radius: 0.7rem; - --segment-pill: color-mix(in oklab, var(--sw-ink) 70%, var(--sw-paper-2)); - --segment-label: color-mix(in oklab, var(--sw-ink-2) 75%, var(--sw-paper-2)); - position: relative; - display: inline-flex; - justify-self: end; - border-radius: var(--segment-radius); - corner-shape: squircle; - background: var(--sw-paper-2); - box-shadow: - 0 1px 2px rgba(15, 17, 21, 0.035), - 0 2px 6px rgba(15, 17, 21, 0.025); - isolation: isolate; -} - -@supports (corner-shape: squircle) { - .header__views { - --segment-radius: calc(var(--segment-height) / 2); - } -} - -.header__views::before { - content: ""; - position: absolute; - position-anchor: --current-view; - top: anchor(top); - right: anchor(right); - bottom: anchor(bottom); - left: anchor(left); - z-index: -1; - border-radius: var(--segment-radius); - corner-shape: squircle; - background: var(--segment-pill); - transition: - left 280ms var(--sw-ease), - right 280ms var(--sw-ease); -} - -.header__view { - height: var(--segment-height); - padding: 0 1.05rem; - border: none; - border-radius: var(--segment-radius); - corner-shape: squircle; - background: transparent; - color: var(--segment-label); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: color 280ms var(--sw-ease); -} - -.header__view:hover { - color: var(--sw-ink-2); -} - -.header__view[aria-pressed="true"] { - anchor-name: --current-view; - color: var(--sw-paper-2); -} - -.header__view:focus-visible { - outline: 1px solid var(--sw-accent); - outline-offset: 1px; -} - -@media (prefers-reduced-motion: reduce) { - .header__views::before { - transition: none; - } -} - -.header__example { - display: flex; - align-items: center; - gap: 0.9rem; -} - -.header__reset { - display: inline-flex; - align-items: center; - gap: 0.4rem; - height: 1.75rem; - border-radius: 0.55rem; - padding: 0 0.65rem; - border: 1px solid var(--sw-hair); - background: var(--sw-paper-2); - color: var(--sw-ink-2); - font: inherit; - font-size: 0.78rem; - cursor: pointer; - animation: header-reset-in 200ms var(--sw-ease); - transition: - color 160ms var(--sw-ease), - border-color 160ms var(--sw-ease); -} - -.header__reset:hover { - border-color: var(--sw-ink-2); - color: var(--sw-ink); -} - -.header__reset:focus-visible { - outline: 1px solid var(--sw-accent); - outline-offset: 1px; -} - -.header__reset-icon { - width: 0.75rem; - height: 0.75rem; -} - -@keyframes header-reset-in { - from { - opacity: 0; - transform: translateX(0.25rem); - } -} - -.workspace { - min-height: 0; -} - -/* The grid's display and templates are inline; the cells only need to clip. */ -.workspace__grid [data-cell] { - min-width: 0; - min-height: 0; - overflow: hidden; -} - -/* A resizable group fills its cell; a fill is a positioned box an editor stretches over. */ -.stack { - height: 100%; -} - -.fill { - position: relative; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; -} - -/* Folds: the layout eases only while a toggle runs, never under a drag. */ -.workspace__grid--easing { - transition: - grid-template-columns var(--fold-duration) var(--sw-ease), - grid-template-rows var(--fold-duration) var(--sw-ease); -} - -.stack--easing > [data-panel] { - transition: flex-grow var(--fold-duration) var(--sw-ease); -} - -/* -- Panel: a head with the title over the body; the head of a folding panel is a toggle -- */ - -.panel { - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -/* Panel heads sit halfway between the page's paper and the panels' lighter body; their title and chevron a step softer than ink-2. */ -.panel__head { - --panel-label: color-mix(in oklab, var(--sw-ink-2) 75%, var(--sw-paper-2)); - display: flex; - background: color-mix(in oklab, var(--sw-paper) 50%, var(--sw-paper-2)); - flex: 0 0 var(--panel-head); - align-items: center; - height: var(--panel-head); - padding: 0 0.75rem; - border-bottom: 1px solid var(--sw-hair-2); - overflow: hidden; - white-space: nowrap; - transition: border-color var(--fold-duration) var(--sw-ease); -} - -.panel__title { - overflow: hidden; - color: var(--panel-label); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - line-height: 1; - text-overflow: ellipsis; - text-transform: uppercase; -} - -.panel__toggle { - display: inline-flex; - align-items: center; - gap: 0.55rem; - min-width: 0; - height: 1.5rem; - margin-left: -0.35rem; - padding: 0 0.35rem; - border: none; - border-radius: 2px; - background: transparent; - color: var(--panel-label); - cursor: pointer; - transition: - background-color 160ms var(--sw-ease), - color 160ms var(--sw-ease); -} - -.panel__toggle:hover { - background: var(--sw-hair-2); - color: var(--sw-ink); -} - -.panel__toggle:hover .panel__title { - color: var(--sw-ink); -} - -.panel__toggle:focus-visible { - outline: 1px solid var(--sw-accent); - outline-offset: 0; -} - -/* The chevron points toward the edge the panel folds against, and turns back once folded. */ -.panel__chevron { - --turn: 45deg; - --shift-x: 0rem; - --shift-y: -0.1rem; - flex: 0 0 auto; - width: 0.42rem; - height: 0.42rem; - border-right: 1.25px solid currentColor; - border-bottom: 1.25px solid currentColor; - transform: translate(var(--shift-x), var(--shift-y)) rotate(var(--turn)); - transition: transform var(--fold-duration) var(--sw-ease); -} - -.panel__chevron[data-points="down"] { - --turn: 45deg; - --shift-x: 0rem; - --shift-y: -0.1rem; -} - -.panel__chevron[data-points="up"] { - --turn: -135deg; - --shift-x: 0rem; - --shift-y: 0.1rem; -} - -.panel__chevron[data-points="left"] { - --turn: 135deg; - --shift-x: 0.1rem; - --shift-y: 0rem; -} - -.panel__chevron[data-points="right"] { - --turn: -45deg; - --shift-x: -0.1rem; - --shift-y: 0rem; -} - -.panel__body { - position: relative; - flex: 1 1 auto; - min-width: 0; - min-height: 0; - background: var(--sw-paper-2); - transition: - opacity 220ms var(--sw-ease), - visibility 0s; -} - -/* Folded, the head stays with its title and the body fades out. */ -/* Controls at the right of a panel's head; a folded panel hides them. */ -.panel__actions { - display: flex; - align-items: center; - gap: 0.75rem; - margin-left: auto; - padding-left: 0.75rem; -} - -.panel[data-collapsed="true"] .panel__actions { - display: none; -} - -/* A switch: its label in the head's mono capitals, then a pill whose knob slides right when on. */ -.switch { - --switch-width: 1.6rem; - --switch-height: 0.9rem; - --switch-inset: 2px; - --switch-knob: calc(var(--switch-height) - 2 * var(--switch-inset)); - - display: inline-flex; - align-items: center; - gap: 0.5rem; - height: 1.5rem; - padding: 0; - border: none; - background: none; - /* In a panel head, the label takes the head's title tone. */ - color: var(--panel-label, var(--sw-ink-2)); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; - transition: color 160ms var(--sw-ease); -} - -.switch:hover:not(:disabled) { - color: var(--sw-ink); -} - -.switch:disabled { - opacity: 0.45; - cursor: default; -} - -.switch:focus-visible { - outline: none; -} - -.switch__track { - position: relative; - flex: 0 0 auto; - width: var(--switch-width); - height: var(--switch-height); - border-radius: calc(var(--switch-height) / 2); - background: var(--sw-hair); - transition: background-color 180ms var(--sw-ease); -} - -.switch__track::after { - content: ""; - position: absolute; - top: var(--switch-inset); - left: var(--switch-inset); - width: var(--switch-knob); - height: var(--switch-knob); - border-radius: 50%; - background: var(--sw-paper-2); - box-shadow: 0 1px 2px rgba(15, 17, 21, 0.25); - transition: translate 200ms var(--sw-ease); -} - -.switch[aria-checked="true"] .switch__track { - background: var(--sw-accent); -} - -.switch[aria-checked="true"] .switch__track::after { - translate: calc(var(--switch-width) - var(--switch-knob) - 2 * var(--switch-inset)) 0; -} - -.switch:focus-visible .switch__track { - outline: 1px solid var(--sw-accent); - outline-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - .switch__track, - .switch__track::after { - transition: none; - } -} - -.panel[data-collapsed="true"] .panel__head { - border-bottom-color: transparent; -} - -.panel[data-collapsed="true"] .panel__body { - opacity: 0; - visibility: hidden; - transition: - opacity 160ms var(--sw-ease), - visibility 0s linear 160ms; -} - -/* Folded against a side, the head turns into a strip: the chevron on top, the title upright under it. */ -.panel[data-stack="horizontal"][data-collapsed="true"] { - flex-direction: row; -} - -.panel[data-stack="horizontal"][data-collapsed="true"] .panel__head { - flex: 0 0 var(--panel-head); - align-items: flex-start; - justify-content: center; - width: var(--panel-head); - height: 100%; - padding: 0.3rem 0; - border-bottom: none; -} - -.panel[data-stack="horizontal"][data-collapsed="true"] .panel__toggle { - flex-direction: column; - gap: 0.7rem; - height: auto; - margin: 0; - padding: 0.5rem 0.3rem; -} - -.panel[data-stack="horizontal"][data-collapsed="true"] .panel__title { - writing-mode: vertical-rl; -} - -/* -- Gridlines: a 1px hairline; a translucent blue-cyan band grows 2px past each side on hover or drag -- */ - -.gridline { - position: relative; - z-index: 1; - background: var(--sw-hair); - transition: background-color 320ms var(--sw-ease); -} - -.gridline--column { - width: 1px; -} - -.gridline--row { - height: 1px; -} - -.gridline::after { - content: ""; - position: absolute; - background: var(--sw-cyan-overlay); - opacity: 0; - transition: - opacity 320ms var(--sw-ease), - transform 360ms var(--sw-ease); - pointer-events: none; -} - -.gridline--column::after { - top: 0; - bottom: 0; - left: -2px; - right: -2px; - transform: scaleX(0.2); -} - -.gridline--row::after { - left: 0; - right: 0; - top: -2px; - bottom: -2px; - transform: scaleY(0.2); -} - -/* The delay sits on the hovered state alone, so entering waits 70ms and leaving is immediate. */ -.gridline[data-separator="hover"], -.gridline[data-separator="active"], -.gridline[data-separator="focus"] { - background: var(--sw-cyan); - transition-delay: 70ms; -} - -.gridline[data-separator="hover"]::after, -.gridline[data-separator="active"]::after, -.gridline[data-separator="focus"]::after { - opacity: 1; - transform: none; - transition-delay: 70ms; -} - -.gridline:focus-visible { - outline: none; -} - -/* -- Marks ---------------------------------------------------------------- */ - -.kicker { - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; - white-space: nowrap; -} - -/* -- Example picker: a button, and a popover listing the examples up the ladder -- */ - -/* The trigger centres its label and chevron; inside the label, the feature and the net share a baseline. */ -.picker__trigger { - anchor-name: --example-picker; - display: inline-flex; - align-items: center; - gap: 0.55rem; - max-width: 24rem; - height: 1.75rem; - padding: 0 0.6rem 0 0.75rem; - border: 1px solid var(--sw-hair); - border-radius: 0.55rem; - background: var(--sw-paper-2); - color: var(--sw-ink); - font: inherit; - font-size: 0.8rem; - line-height: 1; - cursor: pointer; - transition: border-color 160ms var(--sw-ease); -} - -.picker__label { - display: inline-flex; - align-items: baseline; - gap: 0.55rem; - min-width: 0; -} - -.picker__trigger:hover { - border-color: var(--sw-ink-2); -} - -.picker__trigger:focus-visible { - outline: 1px solid var(--sw-accent); - outline-offset: 1px; -} - -.picker__feature { - overflow: hidden; - font-weight: 600; - white-space: nowrap; - text-overflow: ellipsis; -} - -.picker__net { - flex: 0 0 auto; - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.68rem; -} - -.picker__chevron { - flex: 0 0 auto; - align-self: center; - width: 0.4rem; - height: 0.4rem; - margin: 0 0.15rem 0 0.1rem; - border-right: 1.25px solid var(--sw-ink-2); - border-bottom: 1.25px solid var(--sw-ink-2); - /* Rotated, the two borders draw a V whose ink sits low in its box: lift it to the centre. */ - transform: translateY(-0.1rem) rotate(45deg); - transition: transform 220ms var(--sw-ease); -} - -.picker__trigger:has(+ .picker__list:popover-open) .picker__chevron { - transform: translateY(0.1rem) rotate(-135deg); -} - -/* The list sits under the button, right-aligned to it, and fades in. The footer closes it at the bottom. */ -.picker__list { - position-anchor: --example-picker; - inset: auto; - top: anchor(bottom); - right: anchor(right); - width: min(20rem, calc(100vw - 2rem)); - max-height: min(80vh, 32rem); - margin: 0.35rem 0 0; - padding: 0.25rem 0.25rem 0; - border: 1px solid var(--sw-hair); - border-radius: 0.75rem; - background: var(--sw-paper-2); - color: var(--sw-ink); - box-shadow: 0 12px 32px rgba(15, 17, 21, 0.14); - overflow-y: auto; - opacity: 0; - translate: 0 -0.25rem; - transition: - opacity 180ms var(--sw-ease), - translate 220ms var(--sw-ease), - overlay 220ms allow-discrete, - display 220ms allow-discrete; -} - -.picker__list:popover-open { - opacity: 1; - translate: 0 0; -} - -@starting-style { - .picker__list:popover-open { - opacity: 0; - translate: 0 -0.25rem; - } -} - -/* A rung's title recedes behind its examples: small caps in a lighter grey, in line with the features. */ -.picker__group-title { - padding: 0.6rem 0.6rem 0.2rem 1.6rem; - color: color-mix(in oklab, var(--sw-ink-2) 75%, var(--sw-paper-2)); - font-family: var(--sw-mono); - font-size: 0.62rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.picker__option { - display: grid; - grid-template-columns: 1rem minmax(0, 1fr); - align-items: center; - gap: 0.25rem; - width: 100%; - height: 1.875rem; - padding: 0 0.6rem 0 0.35rem; - border: none; - border-radius: 0.5rem; - background: transparent; - color: var(--sw-ink); - font: inherit; - font-size: 0.82rem; - font-weight: 500; - text-align: left; - cursor: pointer; - transition: background-color 140ms var(--sw-ease); -} - -/* The pointer greys a row and the keyboard washes it in the accent, so the two never look alike. */ -.picker__option:hover { - background: var(--sw-hair-2); -} - -.picker__option:focus-visible { - outline: none; - background: var(--sw-accent-wash); -} - -/* The open example: a check and a heavier weight, never a fill, so hover and focus still show on it. */ -.picker__option[aria-selected="true"] { - font-weight: 600; -} - -.picker__option .picker__feature { - font-weight: inherit; -} - -/* Two borders of a box turned a quarter back draw the check, as two borders turned forward draw the chevron. */ -.picker__check { - justify-self: center; - width: 0.5rem; - height: 0.28rem; - border-bottom: 1.5px solid var(--sw-accent); - border-left: 1.5px solid var(--sw-accent); - rotate: -45deg; - translate: 0 -0.08rem; - opacity: 0; -} - -.picker__option[aria-selected="true"] .picker__check { - opacity: 1; -} - -/* The net and the summary of the example under the pointer, else under the keyboard focus. */ -.picker__about { - position: sticky; - bottom: 0; - min-height: 4.4rem; - margin: 0.25rem -0.25rem 0; - padding: 0.55rem 0.95rem 0.6rem; - border-top: 1px solid var(--sw-hair-2); - background: var(--sw-paper-2); - color: var(--sw-ink-2); - font-size: 0.76rem; - line-height: 1.4; -} - -.picker__about-net { - display: block; - margin-bottom: 0.15rem; - color: var(--sw-ink); - font-family: var(--sw-mono); - font-size: 0.62rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -@media (prefers-reduced-motion: reduce) { - .picker__list, - .picker__chevron, - .picker__option { - transition: none; - } -} - -.note { - padding: 0.75rem; - color: var(--sw-ink-2); - font-size: 0.85rem; -} - -/* -- Editors --------------------------------------------------------------- */ - -.monaco-host { - position: absolute; - inset: 0; - user-select: text; - -webkit-user-select: text; -} - -/* - * Expanded chevrons in the gutter rest at a low opacity instead of Monaco's - * zero, and come up while the pointer is over the gutter. Collapsed ones stay - * at full, as Monaco has them. One class more than Monaco's own rules, so the - * order the stylesheets load in does not matter. - */ -.monaco-host .margin-view-overlays .codicon:is(.codicon-folding-expanded, .codicon-folding-manual-expanded) { - opacity: 0.3; -} - -.monaco-host .margin-view-overlays:hover .codicon:is(.codicon-folding-expanded, .codicon-folding-manual-expanded) { - opacity: 1; -} - -.monaco-editor .monaco-hover, -.monaco-editor .suggest-widget { - font-family: var(--sw-sans); -} - -/* Completions and their details panel: slightly rounded, barely lifted. */ -.monaco-editor .suggest-widget, -.monaco-editor .suggest-details { - overflow: hidden; - border-radius: 0.35rem; - box-shadow: 0 2px 6px rgba(15, 17, 21, 0.06); -} - -/* - * "No suggestions." and "Loading...": a quiet note rather than a row. Monaco - * sizes the widget inline to one row, so while it shows a message it takes - * the message's height instead. - */ -.monaco-editor .suggest-widget.message { - height: auto !important; -} - -.monaco-editor .suggest-widget > .message { - padding-block: 3px; - color: color-mix(in oklab, var(--sw-ink-2) 65%, var(--sw-paper-2)); - font-size: 12px; -} - -/* Each row's description at full strength instead of Monaco's 70%. */ -.monaco-editor .suggest-widget .monaco-list .monaco-list-row .details-label { - color: var(--sw-ink-2); - opacity: 1; -} - -/* - * Monaco sizes the details panel inline from a one-line measurement, which - * cuts off a description that wraps. The descriptions are a line or two, so - * the panel takes its content's height instead, capped. - */ -.monaco-editor .suggest-details-container, -.monaco-editor .suggest-details, -.monaco-editor .suggest-details > .monaco-scrollable-element, -.monaco-editor .suggest-details .body { - height: auto !important; - max-height: 12rem; -} - -/* The panel's text clear of the border, with the close button beside its first line. */ -.monaco-editor .suggest-details .docs { - padding: 7px 32px 8px 10px !important; - color: var(--sw-ink); - font-family: var(--sw-sans); - font-size: 12.5px; - line-height: 1.45; -} - -.monaco-editor .suggest-details > .monaco-scrollable-element > .body > .header > .codicon-close { - top: 8px; - right: 8px; - color: var(--sw-ink-2); -} - -/* Hover cards: paper-grey text on ink, roomier, with the mono face for code. */ -.monaco-editor .monaco-hover { - --monaco-monospace-font: var(--sw-mono); - color: #c3c8cf; -} - -/* Monaco writes the editor's font size and line height inline on this node. */ -.monaco-editor .monaco-hover .monaco-hover-content { - font-size: 13px !important; - line-height: 1.45 !important; -} - -.monaco-editor .monaco-hover .hover-contents:not(.html-hover-contents) { - padding: 8px 12px; -} - -.monaco-editor .monaco-hover strong { - color: #f7f8f9; -} - -.monaco-editor .monaco-hover code { - font-family: var(--sw-mono); - font-size: 12px; - color: #edeff1; -} - -/* -- Module view: the editor, the files beside it, the diagnostics under both -- */ - -.module { - display: flex; - flex-direction: column; - height: 100%; - min-height: 0; -} - -.module__main { - position: relative; - flex: 1 1 auto; - min-height: 0; -} - -/* -- Compiler options: sections of options, each with what it does; a dependent option nests under its parent -- */ - -.options { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(14.5rem, 1fr)); - align-content: start; - gap: 1.25rem 1.75rem; - height: 100%; - padding: 0.85rem 0.85rem 1.25rem; - overflow-y: auto; -} - -.options__section { - display: flex; - flex-direction: column; - gap: 0.65rem; - min-width: 0; -} - -.options__title { - margin: 0; - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.66rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.options__about { - margin: -0.35rem 0 0.1rem; - color: var(--sw-ink-2); - font-size: 0.74rem; - line-height: 1.35; -} - -.option { - display: grid; - grid-template-columns: 5.5rem minmax(0, 1fr); - align-items: center; - gap: 0.25rem 0.6rem; - transition: opacity 160ms var(--sw-ease); -} - -/* An option that only matters through the one above hangs from it on a hairline. */ -.option[data-nested="true"] { - margin-left: 0.55rem; - padding-left: 0.7rem; - border-left: 1px solid var(--sw-hair); -} - -.option__label { - color: var(--sw-ink); - font-size: 0.8rem; - font-weight: 500; -} - -.option__control { - width: 100%; - height: 1.6rem; - padding: 0 0.5rem; - border: 1px solid var(--sw-hair); - border-radius: 2px; - background: var(--sw-paper-2); - color: var(--sw-ink); - font-family: var(--sw-mono); - font-size: 0.72rem; - transition: border-color 160ms var(--sw-ease); -} - -.option__control--select { - appearance: none; - padding-right: 1.5rem; - background: - url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23565b66' stroke-width='1.2'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E") - no-repeat right 0.5rem center / 0.55rem auto var(--sw-paper-2); - cursor: pointer; -} - -.option__control:hover:not(:disabled) { - border-color: var(--sw-ink-2); -} - -.option__control:focus-visible { - outline: none; - border-color: var(--sw-accent); -} - -/* Placed and weighted to win over hover and focus, which a field being typed in has. */ -.option__control[aria-invalid="true"]:not(:disabled) { - border-color: var(--sw-red); -} - -.option__control:disabled { - cursor: not-allowed; -} - -.option__hint { - grid-column: 1 / -1; - margin: 0; - color: var(--sw-ink-2); - font-size: 0.72rem; - line-height: 1.4; -} - -/* Off for this net: label and control fade, and the hint says why. */ -.option[data-disabled="true"] .option__label, -.option[data-disabled="true"] .option__control { - opacity: 0.45; -} - -.option[data-disabled="true"] .option__hint { - font-style: italic; -} - -/* -- File list: the emitted files, in the small panel beside the module -- */ - -.file-list { - display: flex; - flex-direction: column; - height: 100%; - padding: 0.4rem 0; - overflow-y: auto; -} - -.file-list__item { - flex: 0 0 auto; - overflow: hidden; - padding: 0.3rem 0.75rem; - border: none; - background: transparent; - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.7rem; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - cursor: pointer; - transition: - color 160ms var(--sw-ease), - background-color 160ms var(--sw-ease); -} - -.file-list__item:hover { - color: var(--sw-ink); -} - -.file-list__item[aria-current="true"] { - color: var(--sw-ink); - background: var(--sw-paper); - box-shadow: inset 2px 0 0 var(--sw-accent); -} - -.diagnostics { - flex: 0 0 auto; - max-height: 30%; - margin: 0; - padding: 0.35rem 0; - border-top: 1px solid var(--sw-hair-2); - list-style: none; - overflow-y: auto; - background: var(--sw-paper); -} - -.diagnostic { - display: flex; - align-items: baseline; - gap: 0.5rem; - padding: 0.15rem 0.75rem; - font-size: 0.78rem; - line-height: 1.35; -} - -.diagnostic__code { - flex: 0 0 auto; - font-family: var(--sw-mono); - font-size: 0.7rem; - color: var(--sw-red); -} - -.diagnostic[data-severity="warning"] .diagnostic__code { - color: var(--sw-ink-2); -} - -.diagnostic__message { - flex: 1 1 auto; - min-width: 0; -} - -.diagnostic__item { - color: var(--sw-ink-2); - font-style: normal; - font-family: var(--sw-mono); - font-size: 0.7rem; -} - -.diagnostic__line { - flex: 0 0 auto; - padding: 0; - border: none; - background: none; - color: var(--sw-accent); - font: inherit; - font-size: 0.72rem; - cursor: pointer; -} - -.diagnostic__line:hover { - text-decoration: underline; -} - -/* -- Line decorations ------------------------------------------------------ */ - -.monaco-editor .line-lit { - background: var(--sw-accent-wash); - animation: line-lit-in 160ms var(--sw-ease); -} - -@keyframes line-lit-in { - from { - background-color: transparent; - } -} - -.monaco-editor .line-error { - background: var(--sw-red-wash); -} - -.monaco-editor .line-warning { - background: rgba(86, 91, 102, 0.08); -} - -.monaco-editor .line-error-mark { - background: var(--sw-red); - width: 2px !important; - margin-left: 2px; -} - -.monaco-editor .line-warning-mark { - background: var(--sw-ink-2); - width: 2px !important; - margin-left: 2px; -} - -/* -- Net preview ----------------------------------------------------------- */ - -.net-frame { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - padding: 0.75rem; - overflow: hidden; -} - -.net { - display: block; - width: 100%; - height: 100%; - font-family: var(--sw-mono); -} - -.net__shape { - fill: var(--sw-paper-2); - stroke: var(--sw-ink); - stroke-width: 1.25; - transition: - stroke 200ms var(--sw-ease), - stroke-width 200ms var(--sw-ease), - fill 200ms var(--sw-ease); -} - -.net__node { - cursor: default; -} - -.net__node[data-lit="true"] .net__shape { - fill: var(--sw-accent-wash); - stroke: var(--sw-accent); - stroke-width: 2; -} - -.net__tokens circle { - fill: var(--sw-ink); - transition: fill 200ms var(--sw-ease); -} - -.net__node[data-lit="true"] .net__tokens circle { - fill: var(--sw-accent); -} - -.net__count { - fill: var(--sw-ink); - font-size: 13px; - font-weight: 600; -} - -.net__label { - fill: var(--sw-ink); - font-size: 11px; - font-weight: 500; - transition: fill 200ms var(--sw-ease); -} - -.net__node[data-lit="true"] .net__label { - fill: var(--sw-accent); -} - -.net__detail, -.net__weight { - fill: var(--sw-ink-2); - font-size: 9px; - letter-spacing: 0.02em; -} - -.net__weight { - paint-order: stroke fill; - stroke: var(--sw-paper-2); - stroke-width: 3px; -} - -.net__line { - fill: none; - stroke: var(--sw-ink-2); - stroke-width: 1.25; - transition: - stroke 200ms var(--sw-ease), - stroke-width 200ms var(--sw-ease); -} - -.net__arc[data-kind="read"] .net__line { - stroke-dasharray: 4 3; -} - -.net__arc[data-lit="true"] .net__line { - stroke: var(--sw-accent); - stroke-width: 1.75; -} - -.net__inhibit { - fill: var(--sw-paper-2); - stroke: context-stroke; - stroke-width: 1.2; -} - -/* -- Documentation: a reading column with room between its parts ---------- */ - -.docs { - height: 100%; - padding: 1.1rem 1.35rem 2rem; - overflow-y: auto; - color: #22262c; - font-size: 0.875rem; - line-height: 1.62; - user-select: text; - -webkit-user-select: text; -} - -.docs > * { - max-width: 60ch; -} - -/* The net a page uses, as a label over its title. */ -.docs .docs__net { - margin: 0 0 0.3rem; - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.docs h2 { - margin: 0 0 0.9rem; - color: var(--sw-ink); - font-size: 1.15rem; - font-weight: 600; - letter-spacing: -0.015em; - line-height: 1.25; -} - -.docs h3 { - margin: 1.5rem 0 0.4rem; - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.docs p { - margin: 0 0 0.95rem; -} - -.docs p:last-child { - margin-bottom: 0; -} - -.docs ul { - margin: 0 0 0.95rem; - padding-left: 1.1rem; -} - -.docs li { - margin: 0 0 0.35rem; -} - -.docs li::marker { - color: var(--sw-ink-2); -} - -.docs strong { - color: var(--sw-ink); - font-weight: 600; -} - -/* Code is quoted as written: no ligature turns != or >= into a symbol the source does not have. */ -.docs code { - font-variant-ligatures: none; - padding: 0.05em 0.3em; - border-radius: 2px; - background: var(--sw-paper); - color: var(--sw-ink); - font-family: var(--sw-mono); - font-size: 0.8em; - white-space: nowrap; -} - -.docs .figure { - max-width: 22rem; - margin: 1.1rem 0 1.25rem; -} - -/* A note stands apart from the reading: a hairline at its side, the text a step quieter. */ -.docs blockquote { - margin: 1.5rem 0 0; - padding: 0.7rem 0.9rem; - border-left: 2px solid var(--sw-hair); - background: var(--sw-paper); - color: var(--sw-ink-2); - font-size: 0.8rem; - line-height: 1.55; -} - -.docs blockquote p { - margin: 0; -} - -/* Math: MathJax SVG at the text's size; a display formula gets a line of its own. */ -.docs mjx-container { - font-size: 1.02em; -} - -.docs mjx-container:not([display="true"]) svg { - vertical-align: -0.22em; -} - -.docs mjx-container[display="true"] { - display: block; - max-width: 100%; - margin: 0.85rem 0 1.05rem; - padding: 0.25rem 0; - overflow-x: auto; - overflow-y: hidden; - text-align: center; -} - -/* Open questions close every page: amber boxes, each naming who can answer it. */ -.open-question { - margin: 0 0 0.75rem; - padding: 0.6rem 0.85rem 0.7rem; - border-left: 2px solid var(--sw-question-line); - border-radius: 0 2px 2px 0; - background: var(--sw-question-wash); -} - -.open-question__owner { - display: block; - margin-bottom: 0.3rem; - color: var(--sw-question); - font-family: var(--sw-mono); - font-size: 0.62rem; - font-weight: 600; - letter-spacing: 0.08em; - line-height: 1.2; - text-transform: uppercase; -} - -.open-question__body { - color: #2b2f36; - font-size: 0.84rem; - line-height: 1.55; -} - -.open-question__body p { - margin: 0; -} - -.open-question__body p + p { - margin-top: 0.5rem; -} - -.docs .open-question code { - background: rgba(255, 255, 255, 0.65); -} - -/* Tables: hairline rows, a mono header, cells that wrap inside the column. */ -.docs table { - width: 100%; - max-width: 60ch; - margin: 0.4rem 0 1.1rem; - border-collapse: collapse; - font-size: 0.78rem; - line-height: 1.45; -} - -.docs th, -.docs td { - padding: 0.4rem 0.6rem 0.4rem 0; - border-bottom: 1px solid var(--sw-hair-2); - text-align: left; - vertical-align: top; - overflow-wrap: break-word; -} - -.docs th { - border-bottom-color: var(--sw-hair); - color: var(--sw-ink-2); - font-family: var(--sw-mono); - font-size: 0.68rem; - font-weight: 500; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.docs tbody th { - width: 22%; - border-bottom-color: var(--sw-hair-2); - letter-spacing: 0; - text-transform: none; -} - -.docs td code { - white-space: normal; -} diff --git a/pocs/zeroth-playground/playground/theme/tokens.css b/pocs/zeroth-playground/playground/theme/tokens.css new file mode 100644 index 00000000..1087891c --- /dev/null +++ b/pocs/zeroth-playground/playground/theme/tokens.css @@ -0,0 +1,78 @@ +/* + * The cascade layers, lowest first. Monaco's own CSS sits in `vendor`, over + * the reset and the prose as it was before they were layered. Every rule + * outside a layer wins over all three, so a component overrides them, and + * Monaco, without a longer selector. + */ +@layer base, prose, vendor; + +/* The swiss theme: paper, ink, one accent, hairlines, Inter and JetBrains Mono. */ +@layer base { + :root { + --paper: #edeff1; + --paper-2: #f7f8f9; + --ink: #0f1115; + --ink-2: #565b66; + --ink-3: color-mix(in oklab, var(--ink-2) 75%, var(--paper-2)); + --ink-wash: rgb(86 91 102 / 0.08); + --text: #22262c; + --text-2: #2b2f36; + --hair: #c3c8cf; + --hair-2: #dde1e6; + --accent: #0b5fff; + --accent-deep: #0a3f9e; + --accent-wash: rgb(11 95 255 / 0.1); + --cyan: #0a8fe0; + --cyan-wash: rgb(0 168 255 / 0.22); + --red: #d6402b; + --red-wash: rgb(214 64 43 / 0.12); + /* Open questions: an amber that reads as unresolved, not as an error. */ + --amber: #9a5b00; + --amber-wash: #fbf1df; + --amber-line: #e0a948; + --veil: rgb(255 255 255 / 0.65); + --chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%23565b66' stroke-width='1.2'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E"); + + --sans: "Inter Variable", "Helvetica Neue", Helvetica, Arial, sans-serif; + --mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + + /* Sizes that several rules share. Sizes close to these stay apart, since merging them moves text. */ + --fs-label-sm: 0.62rem; + --fs-label: 0.68rem; + --fs-code: 0.7rem; + --fs-xs: 0.72rem; + --fs-sm: 0.78rem; + --fs-md: 0.8rem; + --fs-base: 0.875rem; + --fs-title: 1.15rem; + --fs-svg-note: 9px; + --fs-svg: 11px; + + --s-1: 0.25rem; + --s-2: 0.5rem; + --s-3: 0.75rem; + --s-4: 1rem; + --s-5: 1.25rem; + --s-6: 1.5rem; + + --r-xs: 2px; + --r-sm: 0.55rem; + --r-md: 0.75rem; + + --shadow-lift: 0 1px 2px rgb(15 17 21 / 0.035), 0 2px 6px rgb(15 17 21 / 0.025); + --shadow-knob: 0 1px 2px rgb(15 17 21 / 0.25); + --shadow-menu: 0 2px 6px rgb(15 17 21 / 0.06); + --shadow-pop: 0 12px 32px rgb(15 17 21 / 0.14); + + /* Reduced motion zeroes the durations, in base.css. */ + --ease: cubic-bezier(0.2, 0.8, 0.2, 1); + --fast: 160ms; + --base: 200ms; + --slow: 280ms; + /* How long a panel takes to collapse or open; `useFolds` waits as long before it stops easing the layout. */ + --fold: 260ms; + + --header-height: 3rem; + --panel-head: 2rem; + } +} diff --git a/pocs/zeroth-playground/playground/ui/panel.css b/pocs/zeroth-playground/playground/ui/panel.css new file mode 100644 index 00000000..cc9f470a --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/panel.css @@ -0,0 +1,157 @@ +/* A panel: a head with the title over the body; the head of a collapsible panel is a toggle. */ +.panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* Heads sit halfway between the page's paper and the panels' lighter body, their labels a step softer than ink-2. */ +.panel__head { + --panel-label: var(--ink-3); + display: flex; + flex: 0 0 var(--panel-head); + align-items: center; + height: var(--panel-head); + padding: 0 var(--s-3); + border-bottom: 1px solid var(--hair-2); + background: color-mix(in oklab, var(--paper) 50%, var(--paper-2)); + overflow: hidden; + white-space: nowrap; + transition: border-color var(--fold) var(--ease); +} + +.panel__title { + overflow: hidden; + color: var(--panel-label); + line-height: 1; + text-overflow: ellipsis; +} + +.panel__toggle { + display: inline-flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + height: 1.5rem; + margin-left: -0.35rem; + padding: 0 0.35rem; + border-radius: var(--r-xs); + color: var(--panel-label); + transition: + background-color var(--fast) var(--ease), + color var(--fast) var(--ease); + + &:hover { + background: var(--hair-2); + color: var(--ink); + + & .panel__title { + color: var(--ink); + } + } + + &:focus-visible { + outline-offset: 0; + } +} + +/* The chevron points toward the edge the panel collapses against, and turns back once collapsed. */ +.panel__chevron { + --turn: 45deg; + --shift-x: 0rem; + --shift-y: -0.1rem; + flex: 0 0 auto; + width: 0.42rem; + height: 0.42rem; + border-right: 1.25px solid currentColor; + border-bottom: 1.25px solid currentColor; + transform: translate(var(--shift-x), var(--shift-y)) rotate(var(--turn)); + transition: transform var(--fold) var(--ease); + + &[data-points="up"] { + --turn: -135deg; + --shift-y: 0.1rem; + } + + &[data-points="left"] { + --turn: 135deg; + --shift-x: 0.1rem; + --shift-y: 0rem; + } + + &[data-points="right"] { + --turn: -45deg; + --shift-x: -0.1rem; + --shift-y: 0rem; + } +} + +.panel__body { + position: relative; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + background: var(--paper-2); + transition: + opacity var(--base) var(--ease), + visibility 0s; +} + +/* Controls at the right of a panel's head; a collapsed panel hides them. */ +.panel__actions { + display: flex; + align-items: center; + gap: var(--s-3); + margin-left: auto; + padding-left: var(--s-3); +} + +/* Collapsed, the head stays with its title and the body fades out. */ +.panel[data-collapsed="true"] { + & .panel__actions { + display: none; + } + + & .panel__head { + border-bottom-color: transparent; + } + + & .panel__body { + opacity: 0; + visibility: hidden; + transition: + opacity var(--fast) var(--ease), + visibility 0s linear var(--fast); + } +} + +/* Collapsed against a side, the head turns into a strip: the chevron on top, the title upright under it. */ +.panel[data-stack="horizontal"][data-collapsed="true"] { + flex-direction: row; + + & .panel__head { + flex: 0 0 var(--panel-head); + align-items: flex-start; + justify-content: center; + width: var(--panel-head); + height: 100%; + padding: 0.3rem 0; + border-bottom: none; + } + + & .panel__toggle { + flex-direction: column; + gap: 0.7rem; + height: auto; + margin: 0; + padding: var(--s-2) 0.3rem; + } + + & .panel__title { + writing-mode: vertical-rl; + } +} diff --git a/pocs/zeroth-playground/playground/ui/panel.tsx b/pocs/zeroth-playground/playground/ui/panel.tsx index 33d7a80a..dbd435e1 100644 --- a/pocs/zeroth-playground/playground/ui/panel.tsx +++ b/pocs/zeroth-playground/playground/ui/panel.tsx @@ -1,3 +1,5 @@ +import "./panel.css"; + /** * How a panel folds: toward the edge of its stack it sits against. `bottom` * is a panel in a vertical stack, which folds to its head row; `start` and @@ -45,7 +47,7 @@ export const Panel: React.FC = ({ title, fold, actions, children })
    {fold === undefined ? ( - {title} + {title} ) : ( )} {actions === undefined ? null :
    {actions}
    } diff --git a/pocs/zeroth-playground/playground/ui/resizable.css b/pocs/zeroth-playground/playground/ui/resizable.css new file mode 100644 index 00000000..02d8deec --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/resizable.css @@ -0,0 +1,61 @@ +/* A resizable group fills its cell. While a toggle collapses or opens one of its panels, it eases the sizes; never under a drag. */ +.stack { + height: 100%; +} + +.stack--easing > [data-panel] { + transition: flex-grow var(--fold) var(--ease); +} + +/* Gridlines: a 1px hairline; a translucent blue-cyan band grows 2px past each side on hover or drag. */ +.gridline { + position: relative; + z-index: 1; + background: var(--hair); + transition: background-color var(--slow) var(--ease); + + &::after { + content: ""; + position: absolute; + background: var(--cyan-wash); + opacity: 0; + transition: + opacity var(--slow) var(--ease), + transform var(--slow) var(--ease); + pointer-events: none; + } + + /* The delay sits on these states alone, so entering waits 70ms and leaving is immediate. */ + &:is([data-separator="hover"], [data-separator="active"], [data-separator="focus"]) { + background: var(--cyan); + transition-delay: 70ms; + + &::after { + opacity: 1; + transform: none; + transition-delay: 70ms; + } + } + + &:focus-visible { + outline: none; + } +} + +.gridline--column { + width: 1px; + + &::after { + inset: 0 -2px; + transform: scaleX(0.2); + } +} + +.gridline--row { + height: 1px; + + &::after { + inset: -2px 0; + transform: scaleY(0.2); + } +} diff --git a/pocs/zeroth-playground/playground/ui/switch.css b/pocs/zeroth-playground/playground/ui/switch.css new file mode 100644 index 00000000..97b3f1ef --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/switch.css @@ -0,0 +1,65 @@ +/* A switch: its label in the head's mono capitals, then a pill whose knob slides right when on. */ +.switch { + --switch-width: 1.6rem; + --switch-height: 0.9rem; + --switch-inset: 2px; + --switch-knob: calc(var(--switch-height) - 2 * var(--switch-inset)); + display: inline-flex; + align-items: center; + gap: var(--s-2); + height: 1.5rem; + /* In a panel head, the label takes the head's title tone. */ + color: var(--panel-label, var(--ink-2)); + line-height: normal; + transition: color var(--fast) var(--ease); + + &:hover:not(:disabled) { + color: var(--ink); + } + + &:disabled { + opacity: 0.45; + cursor: default; + } + + /* The ring goes round the track instead. */ + &:focus-visible { + outline: none; + } +} + +.switch__track { + position: relative; + flex: 0 0 auto; + width: var(--switch-width); + height: var(--switch-height); + border-radius: calc(var(--switch-height) / 2); + background: var(--hair); + transition: background-color var(--fast) var(--ease); + + &::after { + content: ""; + position: absolute; + top: var(--switch-inset); + left: var(--switch-inset); + width: var(--switch-knob); + height: var(--switch-knob); + border-radius: 50%; + background: var(--paper-2); + box-shadow: var(--shadow-knob); + transition: translate var(--base) var(--ease); + } + + .switch[aria-checked="true"] & { + background: var(--accent); + + &::after { + translate: calc(var(--switch-width) - var(--switch-knob) - 2 * var(--switch-inset)) 0; + } + } + + .switch:focus-visible & { + outline: 1px solid var(--accent); + outline-offset: 2px; + } +} diff --git a/pocs/zeroth-playground/playground/ui/switch.tsx b/pocs/zeroth-playground/playground/ui/switch.tsx index 7c78b45f..ba5528e2 100644 --- a/pocs/zeroth-playground/playground/ui/switch.tsx +++ b/pocs/zeroth-playground/playground/ui/switch.tsx @@ -1,3 +1,5 @@ +import "./switch.css"; + type SwitchProps = { label: string; checked: boolean; @@ -13,7 +15,7 @@ export const Switch: React.FC = ({ label, checked, onChange, disabl + )}

    {KIND_LABELS[stage.kind]} · {OWNER_LABELS[stage.owner]} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css index 0ed08a7d..4d897304 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css +++ b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css @@ -86,49 +86,3 @@ color: var(--red); } } - -.sample__excerpt { - margin: 0; - - & pre { - margin: 0; - padding: 0.4rem 0; - border: 1px solid var(--hair-2); - background: var(--paper-2); - font-family: var(--mono); - font-size: var(--fs-code); - font-variant-ligatures: none; - line-height: 1.6; - overflow-x: auto; - } -} - -.sample__source { - margin-bottom: 0.3rem; - font-size: var(--fs-label-sm); - font-weight: 400; -} - -.sample__line { - display: block; - padding-right: var(--s-3); - white-space: pre; - - &[data-lit="true"] { - background: var(--accent-wash); - } - - /* Lines quoted out of order: a hairline marks where the numbers skip. */ - &[data-skipped="true"] { - border-top: 1px dashed var(--hair); - } -} - -.sample__number { - display: inline-block; - width: 2.6rem; - padding-right: 0.8rem; - color: var(--ink-2); - text-align: right; - opacity: 0.7; -} diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx index 0fee5b93..2013fcb6 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx +++ b/pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx @@ -1,3 +1,5 @@ +import { CodeExcerpt } from "../../ui/code-excerpt"; + import type { StageSample } from "../stage-sample"; import "./sample-view.css"; @@ -27,23 +29,7 @@ export const SampleView: React.FC = ({ exampleTitle, sample }) ))}

    )} - {excerpt === undefined ? null : ( -
    -
    {excerpt.source}
    -
    -            {excerpt.lines.map((line, index) => {
    -              const previous = excerpt.lines[index - 1];
    -              const skipped = previous !== undefined && line.number > previous.number + 1;
    -              return (
    -                
    -                  {line.number}
    -                  {line.text === "" ? " " : line.text}
    -                
    -              );
    -            })}
    -          
    -
    - )} + {excerpt === undefined ? null : } ); }; diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts index 89a64ad4..2a5b59af 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample.ts @@ -10,6 +10,7 @@ import { import { STAGES } from "./stages"; import type { Compilation, CompilerOptions } from "../../compiler"; +import type { Excerpt } from "../ui/excerpt"; import type { Stage, StageId } from "./stages"; /** @@ -38,26 +39,11 @@ export type SampleRow = { error?: boolean; }; -type ExcerptLine = { - /** 1-based, in the source text. */ - number: number; - text: string; - /** A line the sample points at, lit as the editors light it. */ - lit: boolean; -}; - -/** Lines quoted from one text, in order; the numbers may skip. */ -export type SampleExcerpt = { - /** The text the lines come from: `net.py`, `IR`. */ - source: string; - lines: ExcerptLine[]; -}; - export type StageSample = { tone: SampleTone; headline: string; rows: SampleRow[]; - excerpt?: SampleExcerpt; + excerpt?: Excerpt; }; const SAMPLES: Record StageSample> = { diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts index a26b639c..42b3bb3b 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts @@ -1,5 +1,5 @@ import { codeStrings, optionStates, yamlKeys } from "../../../compiler"; -import { NOT_USED, OPTION_SECTIONS, valueLabel } from "../../options/option-labels"; +import { NOT_USED, OPTION_SECTIONS, setOptionLabels, valueLabel } from "../../options/option-labels"; import { excerptOf } from "./excerpt"; import { notParsed } from "./not-reached"; import { diagnosticRow, plural } from "./rows"; @@ -84,10 +84,7 @@ export function optionsSample({ compilation, options }: SampleInput): StageSampl const states = optionStates(ir, options); const applying = states.filter((state) => state.notApplicable === undefined); const labelled = OPTION_SECTIONS.flatMap((section) => section.options); - const set = labelled.flatMap((labels) => { - const value = options[labels.name]; - return value === undefined ? [] : [`${labels.label} ${valueLabel(labels, String(value))}`]; - }); + const set = setOptionLabels(options); const rows = labelled.flatMap((labels) => { const state = states.find((candidate) => candidate.name === labels.name); if (state === undefined) { diff --git a/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts b/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts index 4e482c8f..78658153 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts +++ b/pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts @@ -1,6 +1,12 @@ -import type { SampleExcerpt } from "../stage-sample"; +import { + excerptOf as excerptOfText, + excerptOfLines as excerptOfNumbers, +} from "../../ui/excerpt"; -type LineRange = { startLine: number; endLine: number }; +import type { LineRange } from "../../../compiler"; +import type { Excerpt } from "../../ui/excerpt"; + +export { coveredLines } from "../../ui/excerpt"; /** The most lines a sample quotes. */ export const EXCERPT_LINES = 8; @@ -12,35 +18,19 @@ export function excerptOf( firstLine: number, count = EXCERPT_LINES, lit: readonly number[] = [], -): SampleExcerpt { - const lines = text - .split("\n") - .slice(firstLine - 1, firstLine - 1 + Math.min(count, EXCERPT_LINES)) - .map((line, index) => ({ number: firstLine + index, text: line, lit: lit.includes(firstLine + index) })); - return { source, lines }; +): Excerpt { + return excerptOfText(source, text, firstLine, Math.min(count, EXCERPT_LINES), lit); } /** The listed lines of the text alone, each lit, up to `EXCERPT_LINES`. */ -export function excerptOfLines(source: string, text: string, numbers: readonly number[]): SampleExcerpt { - const lines = text.split("\n"); - return { - source, - lines: numbers.slice(0, EXCERPT_LINES).map((number) => ({ number, text: lines[number - 1] ?? "", lit: true })), - }; +export function excerptOfLines(source: string, text: string, numbers: readonly number[]): Excerpt { + return excerptOfNumbers(source, text, numbers.slice(0, EXCERPT_LINES)); } export function lineSpan({ startLine, endLine }: LineRange): string { return startLine === endLine ? `line ${startLine}` : `lines ${startLine}–${endLine}`; } -/** The lines the ranges cover, each once, in order. */ -export function coveredLines(ranges: readonly LineRange[]): number[] { - const lines = ranges.flatMap(({ startLine, endLine }) => - Array.from({ length: endLine - startLine + 1 }, (_, index) => startLine + index), - ); - return [...new Set(lines)].toSorted((a, b) => a - b); -} - /** Line numbers in order, written as runs: `line 18`, `lines 11, 15, 23–34`. */ export function linesLabel(numbers: readonly number[]): string { const runs: { start: number; end: number }[] = []; diff --git a/pocs/zeroth-playground/playground/docs/example-pages.test.tsx b/pocs/zeroth-playground/playground/docs/example-pages.test.tsx index 0983670f..ff13dced 100644 --- a/pocs/zeroth-playground/playground/docs/example-pages.test.tsx +++ b/pocs/zeroth-playground/playground/docs/example-pages.test.tsx @@ -1,27 +1,27 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { questionById } from "../../semantics/register"; import CompilerGuide from "../compiler-view/compiler-guide.mdx"; import { EXAMPLE_PAGES } from "./example-pages"; import { PAGE_COMPONENTS } from "./page-components"; -/** The owners an open question can name, as `OpenQuestion` types them; MDX is not type-checked. */ -const OWNER = /^(HASH|Zeroth|HASH and Zeroth)$/u; - -/** The owners in a page's markup that `OpenQuestion` does not allow. */ -function strayOwners(html: string): (string | undefined)[] { - const owners = Array.from(html.matchAll(/open-question__owner">For ([^<]*) match[1]); - return owners.filter((owner) => !OWNER.test(owner ?? "")); +/** The question ids the rendered cards carry. */ +function referencedQuestions(html: string): string[] { + return Array.from(html.matchAll(/data-question="([^"]*)"/gu), (match) => match[1] ?? ""); } describe("the example pages", () => { for (const [id, Page] of Object.entries(EXAMPLE_PAGES)) { it(`render ${id}'s page with the components the documentation view passes`, () => { // GIVEN the page and the components the documentation view passes it - // WHEN it renders, which throws on a component it uses but neither imports nor is passed + // WHEN it renders, which throws on a component it uses but neither imports nor is passed, + // and on a question the register does not hold const html = renderToStaticMarkup(); - // THEN each open question names who can settle it - expect(strayOwners(html)).toEqual([]); + // THEN each question card resolved to a question of the register + for (const question of referencedQuestions(html)) { + expect(questionById(question), question).toBeDefined(); + } }); } }); @@ -31,8 +31,7 @@ describe("the Compiler guide", () => { // GIVEN the guide and the components the documentation view passes a page // WHEN it renders const html = renderToStaticMarkup(); - // THEN it has an open question, and each one names who can settle it - expect(html).toContain("open-question__owner"); - expect(strayOwners(html)).toEqual([]); + // THEN it holds its stages section + expect(html).toContain("The stages"); }); }); diff --git a/pocs/zeroth-playground/playground/docs/open-question.css b/pocs/zeroth-playground/playground/docs/open-question.css deleted file mode 100644 index 1f2bc0d6..00000000 --- a/pocs/zeroth-playground/playground/docs/open-question.css +++ /dev/null @@ -1,35 +0,0 @@ -/* Open questions close every page: amber boxes, each naming who can answer it. */ -.open-question { - margin: 0 0 var(--s-3); - padding: 0.6rem 0.85rem 0.7rem; - border-left: 2px solid var(--amber-line); - border-radius: 0 var(--r-xs) var(--r-xs) 0; - background: var(--amber-wash); - - & code { - background: var(--veil); - } -} - -.open-question__owner { - display: block; - margin-bottom: 0.3rem; - color: var(--amber); - font-size: var(--fs-label-sm); - font-weight: 600; - line-height: 1.2; -} - -.open-question__body { - color: var(--text-2); - font-size: 0.84rem; - line-height: 1.55; - - & p { - margin: 0; - } - - & p + p { - margin-top: var(--s-2); - } -} diff --git a/pocs/zeroth-playground/playground/docs/open-question.tsx b/pocs/zeroth-playground/playground/docs/open-question.tsx deleted file mode 100644 index f86e0982..00000000 --- a/pocs/zeroth-playground/playground/docs/open-question.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import "./open-question.css"; - -type OpenQuestionProps = { - /** Who can settle it. */ - owner: "HASH" | "Zeroth" | "HASH and Zeroth"; - /** The question, and why it matters for this net. */ - children: React.ReactNode; -}; - -/** A question the page leaves open, set apart in amber with whoever can answer it. */ -export const OpenQuestion: React.FC = ({ owner, children }) => { - return ( - - ); -}; diff --git a/pocs/zeroth-playground/playground/docs/page-components.ts b/pocs/zeroth-playground/playground/docs/page-components.ts index b16c8c3b..fd48d9f7 100644 --- a/pocs/zeroth-playground/playground/docs/page-components.ts +++ b/pocs/zeroth-playground/playground/docs/page-components.ts @@ -1,6 +1,6 @@ import { CodeBlock } from "./code-block"; import { Figure } from "./figures/figure"; -import { OpenQuestion } from "./open-question"; +import { Question } from "./question-ref"; import type { MDXComponents } from "mdx/types"; @@ -9,4 +9,4 @@ import type { MDXComponents } from "mdx/types"; * view passes them to each page, so the examples import nothing from the * playground. `pre` renders the page's fenced code. */ -export const PAGE_COMPONENTS = { Figure, OpenQuestion, pre: CodeBlock } satisfies MDXComponents; +export const PAGE_COMPONENTS = { Figure, Question, pre: CodeBlock } satisfies MDXComponents; diff --git a/pocs/zeroth-playground/playground/docs/question-ref.css b/pocs/zeroth-playground/playground/docs/question-ref.css new file mode 100644 index 00000000..789ed29c --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/question-ref.css @@ -0,0 +1,50 @@ +/* A question referenced from a page: an amber card naming who can answer it, with a way into the Semantics view. */ +.question-ref { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 0.2rem 0.75rem; + margin: 0 0 var(--s-3); + padding: 0.6rem 0.85rem 0.65rem; + border-left: 2px solid var(--amber-line); + border-radius: 0 var(--r-xs) var(--r-xs) 0; + background: var(--amber-wash); + + /* A settled question has lost its amber: it reads as a note. */ + &[data-status="settled"] { + border-left-color: var(--hair); + background: var(--paper); + + & .question-ref__meta { + color: var(--ink-2); + } + } +} + +.question-ref__meta { + grid-column: 1 / -1; + color: var(--amber); + font-size: var(--fs-label-sm); + font-weight: 600; + line-height: 1.2; +} + +.question-ref__title { + margin: 0; + color: var(--ink); + font-size: 0.84rem; + font-weight: 600; + line-height: 1.45; +} + +.question-ref__open { + color: var(--accent); + font-size: var(--fs-xs); + white-space: nowrap; + transition: color var(--fast) var(--ease); + + &:hover { + color: var(--accent-deep); + text-decoration: underline; + } +} diff --git a/pocs/zeroth-playground/playground/docs/question-ref.tsx b/pocs/zeroth-playground/playground/docs/question-ref.tsx new file mode 100644 index 00000000..b0ba08f1 --- /dev/null +++ b/pocs/zeroth-playground/playground/docs/question-ref.tsx @@ -0,0 +1,29 @@ +import { questionById } from "../../semantics/register"; +import { useNavigation } from "../app/navigation"; + +import "./question-ref.css"; + +/** + * A question of the semantics register, named on a page: who can settle it, + * its status and its title, and a way to open it in the Semantics view. The + * text of the question lives in the register alone. An id the register does + * not hold throws, so the page render test catches it. + */ +export const Question: React.FC<{ id: string }> = ({ id }) => { + const question = questionById(id); + if (question === undefined) { + throw new Error(`no question ${id} in semantics/`); + } + const { openQuestion } = useNavigation(); + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/options/option-labels.ts b/pocs/zeroth-playground/playground/options/option-labels.ts index 8885ffba..0ccb5eab 100644 --- a/pocs/zeroth-playground/playground/options/option-labels.ts +++ b/pocs/zeroth-playground/playground/options/option-labels.ts @@ -1,4 +1,4 @@ -import type { OptionName } from "../../compiler"; +import type { CompilerOptions, OptionName } from "../../compiler"; /** * How the compiler options read in the panel: grouped by what they decide, @@ -31,6 +31,14 @@ export function valueLabel(labels: OptionLabels, value: string): string { */ export const NOT_USED = "not used"; +/** The options set, each as its label and value label, in the panel's order: `Shape modular`, `Time step 0.5`. */ +export function setOptionLabels(options: CompilerOptions): string[] { + return OPTION_SECTIONS.flatMap((section) => section.options).flatMap((labels) => { + const value = options[labels.name]; + return value === undefined ? [] : [`${labels.label} ${valueLabel(labels, String(value))}`]; + }); +} + export type OptionSection = { title: string; /** What the section's options decide. */ diff --git a/pocs/zeroth-playground/playground/scripts/screenshot.ts b/pocs/zeroth-playground/playground/scripts/screenshot.ts index 6b5c8818..eb9f6a3d 100644 --- a/pocs/zeroth-playground/playground/scripts/screenshot.ts +++ b/pocs/zeroth-playground/playground/scripts/screenshot.ts @@ -1,7 +1,8 @@ /** * Screenshots every example the picker lists at 1440x900 into `screenshots/`, - * then the compiler view, opening `dist/index.html` from disk as a reader - * would. Run `pnpm build` first, then `pnpm screenshot`. + * then the Compiler view and the Semantics view with and without a question, + * opening `dist/index.html` from disk as a reader would. Run `pnpm build` + * first, then `pnpm screenshot`. */ import { mkdir } from "node:fs/promises"; import path from "node:path"; @@ -49,6 +50,20 @@ try { const compiler = path.join(out, "compiler.png"); await tab.screenshot({ path: compiler }); console.log(`wrote ${path.relative(root, compiler)}`); + // The Semantics view: the intro with the list, then one question selected from its hash. + for (const [hash, name] of [ + ["semantics", "semantics.png"], + ["semantics/ties-under-clocks", "semantics-question.png"], + ] as const) { + await tab.goto(`${page}#${hash}`); + await tab.reload(); + await tab.locator(".question-list__row").filter({ visible: true }).first().waitFor(); + await tab.evaluate(() => document.fonts.ready); + await tab.waitForTimeout(400); + const file = path.join(out, name); + await tab.screenshot({ path: file }); + console.log(`wrote ${path.relative(root, file)}`); + } } finally { await browser.close(); } diff --git a/pocs/zeroth-playground/playground/semantics-view/evidence.test.ts b/pocs/zeroth-playground/playground/semantics-view/evidence.test.ts new file mode 100644 index 00000000..d4eccd1e --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/evidence.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { exampleById } from "../../examples/catalog"; +import { EVIDENCE_LINES, evidenceOf } from "./evidence"; + +import type { Example } from "../../examples/catalog"; + +function example(id: string): Example { + const found = exampleById(id); + if (found === undefined) { + throw new Error(`no example ${id}`); + } + return found; +} + +describe("evidenceOf", () => { + it("quotes the lines of the item, numbered as in the file, where the numbers may skip", () => { + // GIVEN Conflict with rates under clocks and nondet conflicts, and the place Pool + // WHEN the evidence is read + const evidence = evidenceOf(example("conflict-clocked"), { + example: "conflict-clocked", + options: { rates: "clock", conflicts: "nondet" }, + item: { kind: "place", name: "Pool" }, + }); + // THEN it quotes net.py's lines of the place: its variable, its class and its instance + if (evidence.kind !== "lines") { + throw new Error("expected lines"); + } + expect(evidence.excerpt.source).toBe("net.py · place Pool"); + const numbers = evidence.excerpt.lines.map((line) => line.number); + expect(numbers[0]).toBe(7); + expect(evidence.excerpt.lines[0]?.text).toBe("Pool = Var(Nat())"); + expect(numbers).toContain(65); + expect(numbers.at(-1)).toBe(104); + expect(evidence.excerpt.lines.every((line) => !line.lit)).toBe(true); + }); + + it("cuts a long quote and says so in the source", () => { + // GIVEN the same net and the transition TakeRight, which owns more lines than the cap + // WHEN the evidence is read + const evidence = evidenceOf(example("conflict-clocked"), { + example: "conflict-clocked", + options: { rates: "clock", conflicts: "nondet" }, + item: { kind: "transition", name: "TakeRight" }, + }); + // THEN the first lines up to the cap are quoted, and the source counts the rest + if (evidence.kind !== "lines") { + throw new Error("expected lines"); + } + expect(evidence.excerpt.lines).toHaveLength(EVIDENCE_LINES); + expect(evidence.excerpt.source).toMatch(/^net\.py · transition TakeRight · first 14 of \d+ lines$/u); + }); + + it("quotes the opening lines of the file without an item", () => { + // GIVEN Cycle under its defaults, with no item named + // WHEN the evidence is read + const evidence = evidenceOf(example("cycle"), { example: "cycle" }); + // THEN the first lines of net.py are quoted from line 1 + expect(evidence).toMatchObject({ kind: "lines", excerpt: { source: "net.py · first 12 lines" } }); + if (evidence.kind === "lines") { + expect(evidence.excerpt.lines.map((line) => line.number)).toEqual( + Array.from({ length: 12 }, (_, index) => index + 1), + ); + } + }); + + it("quotes the first diagnostic when the compile refuses", () => { + // GIVEN Bucket under the modular shape, which the lowering refuses + // WHEN the evidence is read + const evidence = evidenceOf(example("bucket"), { example: "bucket", options: { shape: "modular" } }); + // THEN the refusal's code and message stand in for the lines + expect(evidence.kind).toBe("refused"); + if (evidence.kind === "refused") { + expect(evidence.message).toMatch(/^modular-coloured-not-lowered: /u); + } + }); +}); diff --git a/pocs/zeroth-playground/playground/semantics-view/evidence.ts b/pocs/zeroth-playground/playground/semantics-view/evidence.ts new file mode 100644 index 00000000..97a01964 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/evidence.ts @@ -0,0 +1,44 @@ +import { compile, itemLabel, linesOfItem } from "../../compiler"; +import { coveredLines, excerptOf, excerptOfLines } from "../ui/excerpt"; + +import type { Example } from "../../examples/catalog"; +import type { Showing } from "../../semantics/register"; +import type { Excerpt } from "../ui/excerpt"; + +/** + * What a question's showing quotes: the example compiled at the showing's + * options, and the lines of its first file that the net item owns; the first + * lines of the file without an item. A compile that refuses quotes its first + * diagnostic instead. Pure, and independent of the document the Examples + * view has open. + */ +export type Evidence = { kind: "lines"; excerpt: Excerpt } | { kind: "refused"; message: string }; + +/** The most lines a showing quotes. */ +export const EVIDENCE_LINES = 14; + +/** The lines quoted when the showing names no item. */ +const OPENING_LINES = 12; + +export function evidenceOf(example: Example, showing: Showing): Evidence { + const compilation = compile(example.ir, { options: showing.options }); + const file = compilation.files[0]; + if (file === undefined) { + const error = compilation.errors[0]; + return { + kind: "refused", + message: error === undefined ? "Refused without a diagnostic." : `${error.code}: ${error.message}`, + }; + } + const { item } = showing; + if (item === undefined) { + return { kind: "lines", excerpt: excerptOf(`${file.path} · first ${OPENING_LINES} lines`, file.text, 1, OPENING_LINES) }; + } + const lines = coveredLines(linesOfItem(file.trace, item)); + const shown = lines.slice(0, EVIDENCE_LINES); + const cut = lines.length > shown.length ? ` · first ${shown.length} of ${lines.length} lines` : ""; + return { + kind: "lines", + excerpt: excerptOfLines(`${file.path} · ${itemLabel(item)}${cut}`, file.text, shown, false), + }; +} diff --git a/pocs/zeroth-playground/playground/semantics-view/question-card.css b/pocs/zeroth-playground/playground/semantics-view/question-card.css new file mode 100644 index 00000000..4d4bcb43 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/question-card.css @@ -0,0 +1,45 @@ +/* The question panel with a question selected: the head, the page, then where it shows. */ +.question-card { + height: 100%; + padding: var(--s-4) 1.35rem var(--s-6); + overflow-y: auto; + user-select: text; +} + +/* The head leads with the way back, a hairline, then the topic, the owner and the status. */ +.question-card__head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.45rem; + + & .kicker { + margin: 0; + } +} + +.back-link + .kicker { + padding-left: 0.6rem; + border-left: 1px solid var(--hair); +} + +.question-card__doc { + max-width: 60ch; + + & code { + font-size: 0.82em; + } +} + +.question-card__title { + margin: 0 0 0.6rem; +} + +.question-card__shows { + max-width: 72rem; + margin-top: var(--s-5); +} + +.question-card__shows-title { + margin: 0 0 var(--s-2); +} diff --git a/pocs/zeroth-playground/playground/semantics-view/question-card.tsx b/pocs/zeroth-playground/playground/semantics-view/question-card.tsx new file mode 100644 index 00000000..762867b3 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/question-card.tsx @@ -0,0 +1,62 @@ +import { flushSync } from "react-dom"; + +import { topicById } from "../../semantics/register"; +import { PAGE_COMPONENTS } from "../docs/page-components"; +import { BackLink } from "../ui/back-link"; +import { QUESTION_PAGES } from "./question-pages"; +import { rowId } from "./question-list"; +import { ShowingView } from "./showing-view"; + +import type { Question } from "../../semantics/register"; + +import "./question-card.css"; + +type QuestionCardProps = { + question: Question; + /** Deselects the question; the card hands the focus back to its row in the list. */ + onBack: () => void; +}; + +/** + * The selected question: its topic, owner and status over its title, the + * page, and then where it shows, one block per example compiled at the + * options that bring the behaviour out. + */ +export const QuestionCard: React.FC = ({ question, onBack }) => { + const Page = QUESTION_PAGES[question.id]; + const topic = topicById(question.topic); + return ( +
    +
    + { + // The link goes with the card: hand the focus to the question's row in the list. + flushSync(onBack); + document.getElementById(rowId(question.id))?.focus(); + }} + > + All questions + +

    + {topic.title} · For {question.owner} · {question.status} +

    +
    +
    +

    {question.title}

    + {Page === undefined ? ( +

    This question has no page.

    + ) : ( + + )} +
    +
    +

    Shows in

    + {question.shows.length === 0 ? ( +

    No example brings this out yet.

    + ) : ( + question.shows.map((showing, index) => ) + )} +
    +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/semantics-view/question-list.css b/pocs/zeroth-playground/playground/semantics-view/question-list.css new file mode 100644 index 00000000..f946a846 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/question-list.css @@ -0,0 +1,81 @@ +/* The questions by topic: a status dot, the title, and the owner in small mono at the right. */ +.question-list { + height: 100%; + padding: var(--s-2) var(--s-2) var(--s-4); + overflow-y: auto; +} + +.question-list__group { + margin-bottom: var(--s-2); +} + +/* A topic's title recedes behind its questions, in line with their titles. */ +.question-list__topic { + padding: 0.6rem 0.6rem 0.25rem 1.55rem; + color: var(--ink-3); + font-size: var(--fs-label-sm); +} + +.question-list__row { + display: grid; + grid-template-columns: 0.95rem minmax(0, 1fr) auto; + align-items: center; + gap: var(--s-2); + width: 100%; + min-height: 1.9rem; + padding: 0.3rem 0.6rem; + border-radius: 0.5rem; + font-size: 0.82rem; + font-weight: 500; + line-height: 1.35; + text-align: left; + transition: background-color var(--fast) var(--ease); + + /* The pointer greys a row and the keyboard washes it in the accent, so the two never look alike. */ + &:hover { + background: var(--hair-2); + } + + &:focus-visible { + outline: none; + background: var(--accent-wash); + } + + /* The selected question: a heavier weight and a wash of ink, which hover and focus still show over. */ + &[aria-selected="true"] { + background: var(--ink-wash); + font-weight: 600; + + &:hover { + background: var(--hair-2); + } + } +} + +/* The status as a dot: amber while open, blue once proposed, grey once settled. */ +.question-list__status { + justify-self: center; + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--amber-line); + + &[data-status="proposed"] { + background: var(--accent); + } + + &[data-status="settled"] { + background: var(--ink-3); + } +} + +.question-list__title { + overflow-wrap: anywhere; +} + +.question-list__owner { + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-label-sm); + white-space: nowrap; +} diff --git a/pocs/zeroth-playground/playground/semantics-view/question-list.tsx b/pocs/zeroth-playground/playground/semantics-view/question-list.tsx new file mode 100644 index 00000000..71702f52 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/question-list.tsx @@ -0,0 +1,82 @@ +import { QUESTIONS, TOPICS, questionsOf } from "../../semantics/register"; +import { stepIndex } from "../ui/roving"; + +import type { Status } from "../../semantics/register"; + +import "./question-list.css"; + +type QuestionListProps = { + selected: string | null; + onSelect: (id: string) => void; +}; + +/** What the status dot says on hover. */ +const STATUS_TITLES: Record = { + open: "Open: nobody has decided", + proposed: "Proposed: one side proposes an answer", + settled: "Settled: decided, with the decision on the page", +}; + +/** The id of a question's row, for the card to hand the focus back to. */ +export function rowId(question: string): string { + return `question-row-${question}`; +} + +/** The arrow keys walk the rows; Home and End jump to the ends. Enter and Space press the row. */ +function walk(event: React.KeyboardEvent) { + const rows = [...event.currentTarget.querySelectorAll('[role="option"]')]; + const at = rows.indexOf(document.activeElement as HTMLButtonElement); + const next = stepIndex(event.key, at, rows.length); + if (next === null) { + return; + } + event.preventDefault(); + rows[next]?.focus(); +} + +/** + * Every question of the register, grouped by topic: a status dot, the title, + * and who can settle it. A click selects; a hover does nothing. The selected + * row, else the first, is the one Tab reaches, and the arrow keys walk the rest. + */ +export const QuestionList: React.FC = ({ selected, onSelect }) => { + const tabStop = selected ?? QUESTIONS[0]?.id; + return ( +
    + {TOPICS.map((topic) => { + const questions = questionsOf(topic.id); + const titleId = `question-topic-${topic.id}`; + return questions.length === 0 ? null : ( +
    +
    + {topic.title} +
    + {questions.map((question) => ( + + ))} +
    + ); + })} +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/semantics-view/question-pages.ts b/pocs/zeroth-playground/playground/semantics-view/question-pages.ts new file mode 100644 index 00000000..8ef147e0 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/question-pages.ts @@ -0,0 +1,10 @@ +import { folderOf } from "../../examples/catalog"; + +import type { MDXContent } from "mdx/types"; + +/** Each question folder's `page.mdx`, keyed by the folder, as the register is. */ +export const QUESTION_PAGES: Readonly> = Object.fromEntries( + Object.entries( + import.meta.glob("../../semantics/*/page.mdx", { eager: true, import: "default" }), + ).map(([path, page]) => [folderOf(path), page]), +); diff --git a/pocs/zeroth-playground/playground/semantics-view/semantics-intro.css b/pocs/zeroth-playground/playground/semantics-view/semantics-intro.css new file mode 100644 index 00000000..2d368de8 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/semantics-intro.css @@ -0,0 +1,32 @@ +/* The topics under the intro: each with its count and one line on what it holds. */ +.semantics-intro__topics { + margin: 0; +} + +.semantics-intro__topic { + padding: 0.5rem 0; + border-top: 1px solid var(--hair-2); + + & dt { + display: flex; + align-items: baseline; + gap: 0.6rem; + color: var(--ink); + font-size: var(--fs-md); + font-weight: 600; + } + + & dd { + margin: 0.15rem 0 0; + color: var(--text-2); + font-size: var(--fs-sm); + line-height: 1.5; + } +} + +.semantics-intro__count { + color: var(--ink-2); + font-family: var(--mono); + font-size: var(--fs-label-sm); + font-weight: 400; +} diff --git a/pocs/zeroth-playground/playground/semantics-view/semantics-intro.tsx b/pocs/zeroth-playground/playground/semantics-view/semantics-intro.tsx new file mode 100644 index 00000000..a929df24 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/semantics-intro.tsx @@ -0,0 +1,31 @@ +import Intro from "../../semantics/intro.mdx"; +import { TOPICS, questionsOf } from "../../semantics/register"; +import { PAGE_COMPONENTS } from "../docs/page-components"; + +import "./semantics-intro.css"; + +/** The question panel with nothing selected: the intro, then each topic with what it holds and how many questions. */ +export const SemanticsIntro: React.FC = () => { + return ( +
    + +

    Topics

    +
    + {TOPICS.map((topic) => { + const count = questionsOf(topic.id).length; + return ( +
    +
    + {topic.title} + + {count} {count === 1 ? "question" : "questions"} + +
    +
    {topic.about}
    +
    + ); + })} +
    +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx b/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx new file mode 100644 index 00000000..3e8f685e --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx @@ -0,0 +1,63 @@ +import { Group, Panel as ResizablePanel, Separator, usePanelRef } from "react-resizable-panels"; + +import { questionById } from "../../semantics/register"; +import { Panel } from "../ui/panel"; +import { RESIZE_TARGET } from "../ui/resize-target"; +import { useFolds } from "../ui/use-folds"; +import { QuestionCard } from "./question-card"; +import { QuestionList } from "./question-list"; +import { SemanticsIntro } from "./semantics-intro"; + +import "../ui/resizable.css"; + +/** The one panel of the view that folds. */ +const FOLDS = ["questions"] as const; + +type SemanticsViewProps = { + /** The selected question's id; the app owns it, so a page can open a question here. */ + selected: string | null; + onSelect: (id: string | null) => void; +}; + +/** + * The questions on the semantics of the compilation: the list by topic on + * the left, and on the right the selected question's card, or the intro and + * the topics when none is selected. The view reads the register and the + * catalog alone, never the document the Examples view has open. + */ +export const SemanticsView: React.FC = ({ selected, onSelect }) => { + const listRef = usePanelRef(); + const folds = useFolds(FOLDS, () => listRef.current); + const question = selected === null ? undefined : questionById(selected); + return ( + + + + + + + + + + {question === undefined ? ( + + ) : ( + onSelect(null)} /> + )} + + + + ); +}; diff --git a/pocs/zeroth-playground/playground/semantics-view/showing-view.css b/pocs/zeroth-playground/playground/semantics-view/showing-view.css new file mode 100644 index 00000000..2e09e357 --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/showing-view.css @@ -0,0 +1,70 @@ +/* One place a question shows: the example, its options, a way to open it, and the quoted lines. */ +.showing { + margin: 0 0 var(--s-5); + padding-top: var(--s-3); + border-top: 1px solid var(--hair-2); +} + +.showing__head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: baseline; + gap: 0.2rem 0.75rem; + margin-bottom: 0.6rem; +} + +.showing__example { + display: flex; + align-items: baseline; + gap: 0.55rem; + margin: 0; + font-size: 0.85rem; +} + +.showing__feature { + font-weight: 600; +} + +.showing__net { + font-size: var(--fs-label-sm); +} + +/* The options compiled under, as the panel labels them: "Rates clock · Conflicts nondet". */ +.showing__options { + grid-column: 1; + margin: 0; + font-size: var(--fs-label-sm); +} + +.showing__open { + grid-row: 1 / 3; + grid-column: 2; + align-self: center; + height: 1.6rem; + padding: 0 0.6rem; + border: 1px solid var(--hair); + border-radius: var(--r-sm); + background: var(--paper-2); + color: var(--accent); + font-size: var(--fs-xs); + white-space: nowrap; + transition: + color var(--fast) var(--ease), + border-color var(--fast) var(--ease); + + &:hover { + border-color: var(--accent); + color: var(--accent-deep); + } +} + +/* A compile that refuses: its first diagnostic in place of the lines. */ +.showing__refused { + margin: 0; + padding: 0.45rem 0.6rem; + border: 1px solid var(--hair-2); + color: var(--red); + font-family: var(--mono); + font-size: var(--fs-code); + overflow-wrap: anywhere; +} diff --git a/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx b/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx new file mode 100644 index 00000000..5ca7e71e --- /dev/null +++ b/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx @@ -0,0 +1,49 @@ +import { exampleById } from "../../examples/catalog"; +import { useNavigation } from "../app/navigation"; +import { setOptionLabels } from "../options/option-labels"; +import { CodeExcerpt } from "../ui/code-excerpt"; +import { evidenceOf } from "./evidence"; + +import type { Showing } from "../../semantics/register"; + +import "./showing-view.css"; + +/** + * One place a question shows: the example and the options that bring the + * behaviour out, a way to open them in the Examples view, and the lines the + * compiler writes for the net item, compiled here from the catalog. + */ +export const ShowingView: React.FC<{ showing: Showing }> = ({ showing }) => { + const { openExample } = useNavigation(); + const example = exampleById(showing.example); + if (example === undefined) { + return

    The catalog has no example {showing.example}.

    ; + } + const options = showing.options ?? {}; + const labels = setOptionLabels(options); + const evidence = evidenceOf(example, showing); + return ( +
    +
    +

    + {example.feature} + {example.title} +

    +

    {labels.length === 0 ? "Default options" : labels.join(" · ")}

    + +
    + {evidence.kind === "refused" ? ( +

    {evidence.message}

    + ) : ( + + )} +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/theme/base.css b/pocs/zeroth-playground/playground/theme/base.css index 8d1a9fbe..eb26d738 100644 --- a/pocs/zeroth-playground/playground/theme/base.css +++ b/pocs/zeroth-playground/playground/theme/base.css @@ -56,6 +56,12 @@ font-size: 0.85rem; } + /* A label over a title: a stage's kind, "Overview", the example a sample reads, a question's topic. */ + .kicker { + margin: 0 0 0.45rem; + white-space: nowrap; + } + @media (prefers-reduced-motion: reduce) { :root { --fast: 0s; diff --git a/pocs/zeroth-playground/playground/ui/back-link.css b/pocs/zeroth-playground/playground/ui/back-link.css new file mode 100644 index 00000000..ae6bb2f5 --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/back-link.css @@ -0,0 +1,44 @@ +/* + * The padding gives the focus wash room. The negative margins take it back, + * so the chevron sits on the card's left edge and a head with the link is as + * tall as one without. + */ +.back-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin: -0.2rem 0 -0.2rem -0.4rem; + padding: 0.2rem 0.45rem 0.2rem 0.4rem; + border-radius: 0.4rem; + color: var(--accent); + line-height: 1; + white-space: nowrap; + transition: + color var(--fast) var(--ease), + background-color var(--fast) var(--ease); + + &:hover { + color: var(--accent-deep); + } + + &:focus-visible { + outline: none; + background: var(--accent-wash); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent) 35%, transparent); + } +} + +/* Two borders of a box turned an eighth draw the chevron, pointing back. */ +.back-link__chevron { + width: 0.36rem; + height: 0.36rem; + border-bottom: 1.5px solid currentColor; + border-left: 1.5px solid currentColor; + rotate: 45deg; + translate: 0.05rem 0; + transition: translate var(--fast) var(--ease); + + .back-link:hover & { + translate: -0.1rem 0; + } +} diff --git a/pocs/zeroth-playground/playground/ui/back-link.tsx b/pocs/zeroth-playground/playground/ui/back-link.tsx new file mode 100644 index 00000000..7995213e --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/back-link.tsx @@ -0,0 +1,18 @@ +import "./back-link.css"; + +type BackLinkProps = { + /** An id another element hands the keyboard focus to. */ + id?: string; + onClick: () => void; + children: React.ReactNode; +}; + +/** The way back from a card to the list it came from: a chevron and a label, like a breadcrumb. */ +export const BackLink: React.FC = ({ id, onClick, children }) => { + return ( + + ); +}; diff --git a/pocs/zeroth-playground/playground/ui/code-excerpt.css b/pocs/zeroth-playground/playground/ui/code-excerpt.css new file mode 100644 index 00000000..5faba373 --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/code-excerpt.css @@ -0,0 +1,46 @@ +/* Lines quoted from a text: numbered, under the text's name, in a hairline box. */ +.code-excerpt { + margin: 0; + + & pre { + margin: 0; + padding: 0.4rem 0; + border: 1px solid var(--hair-2); + background: var(--paper-2); + font-family: var(--mono); + font-size: var(--fs-code); + font-variant-ligatures: none; + line-height: 1.6; + overflow-x: auto; + } +} + +.code-excerpt__source { + margin-bottom: 0.3rem; + font-size: var(--fs-label-sm); + font-weight: 400; +} + +.code-excerpt__line { + display: block; + padding-right: var(--s-3); + white-space: pre; + + &[data-lit="true"] { + background: var(--accent-wash); + } + + /* Lines quoted out of order: a hairline marks where the numbers skip. */ + &[data-skipped="true"] { + border-top: 1px dashed var(--hair); + } +} + +.code-excerpt__number { + display: inline-block; + width: 2.6rem; + padding-right: 0.8rem; + color: var(--ink-2); + text-align: right; + opacity: 0.7; +} diff --git a/pocs/zeroth-playground/playground/ui/code-excerpt.tsx b/pocs/zeroth-playground/playground/ui/code-excerpt.tsx new file mode 100644 index 00000000..27f003f4 --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/code-excerpt.tsx @@ -0,0 +1,28 @@ +import type { Excerpt } from "./excerpt"; + +import "./code-excerpt.css"; + +/** + * A few lines quoted from a text, numbered as in the source, under the name + * of the text. A lit line is washed as the editors wash it, and a dashed + * hairline marks where the numbers skip. + */ +export const CodeExcerpt: React.FC<{ excerpt: Excerpt }> = ({ excerpt }) => { + return ( +
    +
    {excerpt.source}
    +
    +        {excerpt.lines.map((line, index) => {
    +          const previous = excerpt.lines[index - 1];
    +          const skipped = previous !== undefined && line.number > previous.number + 1;
    +          return (
    +            
    +              {line.number}
    +              {line.text === "" ? " " : line.text}
    +            
    +          );
    +        })}
    +      
    +
    + ); +}; diff --git a/pocs/zeroth-playground/playground/ui/excerpt.ts b/pocs/zeroth-playground/playground/ui/excerpt.ts new file mode 100644 index 00000000..5a793e79 --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/excerpt.ts @@ -0,0 +1,58 @@ +import type { LineRange } from "../../compiler"; + +/** + * Lines quoted from one text, with their numbers: what a live sample or a + * question's evidence shows of a file. `CodeExcerpt` renders one. + */ + +export type ExcerptLine = { + /** 1-based, in the source text. */ + number: number; + text: string; + /** A line the excerpt points at, lit as the editors light it. */ + lit: boolean; +}; + +/** Lines quoted from one text, in order; the numbers may skip. */ +export type Excerpt = { + /** The text the lines come from: `net.py`, `IR`. */ + source: string; + lines: ExcerptLine[]; +}; + +/** `count` lines of the text from `firstLine`, lighting the ones listed. */ +export function excerptOf( + source: string, + text: string, + firstLine: number, + count: number, + lit: readonly number[] = [], +): Excerpt { + const lines = text + .split("\n") + .slice(firstLine - 1, firstLine - 1 + count) + .map((line, index) => ({ number: firstLine + index, text: line, lit: lit.includes(firstLine + index) })); + return { source, lines }; +} + +/** The listed lines of the text alone, lit unless told otherwise. */ +export function excerptOfLines( + source: string, + text: string, + numbers: readonly number[], + lit = true, +): Excerpt { + const lines = text.split("\n"); + return { + source, + lines: numbers.map((number) => ({ number, text: lines[number - 1] ?? "", lit })), + }; +} + +/** The lines the ranges cover, each once, in order. */ +export function coveredLines(ranges: readonly LineRange[]): number[] { + const lines = ranges.flatMap(({ startLine, endLine }) => + Array.from({ length: endLine - startLine + 1 }, (_, index) => startLine + index), + ); + return [...new Set(lines)].toSorted((a, b) => a - b); +} diff --git a/pocs/zeroth-playground/playground/ui/roving.test.ts b/pocs/zeroth-playground/playground/ui/roving.test.ts new file mode 100644 index 00000000..cfde1d8c --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/roving.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { stepIndex } from "./roving"; + +describe("stepIndex", () => { + it("steps down and up, and stays within the list", () => { + // GIVEN a list of four options with the focus on the second + // WHEN the arrow keys are pressed + // THEN the focus moves one step, and a step past an end stays at it + expect(stepIndex("ArrowDown", 1, 4)).toBe(2); + expect(stepIndex("ArrowUp", 1, 4)).toBe(0); + expect(stepIndex("ArrowUp", 0, 4)).toBe(0); + expect(stepIndex("ArrowDown", 3, 4)).toBe(3); + }); + + it("jumps to the ends on Home and End, and ignores other keys", () => { + // GIVEN a list of four options with the focus on the second + // THEN Home and End reach the ends, and a letter moves nothing + expect(stepIndex("Home", 1, 4)).toBe(0); + expect(stepIndex("End", 1, 4)).toBe(3); + expect(stepIndex("a", 1, 4)).toBeNull(); + }); + + it("steps into the list from no focus", () => { + // GIVEN no option focused, read as index -1 + // WHEN ArrowDown is pressed + // THEN the first option takes the focus + expect(stepIndex("ArrowDown", -1, 4)).toBe(0); + }); +}); diff --git a/pocs/zeroth-playground/playground/ui/roving.ts b/pocs/zeroth-playground/playground/ui/roving.ts new file mode 100644 index 00000000..316a8d7f --- /dev/null +++ b/pocs/zeroth-playground/playground/ui/roving.ts @@ -0,0 +1,18 @@ +/** + * Where the arrow keys move the focus in a list of options: ArrowDown and + * ArrowUp step from `at`, Home and End jump to the ends, and the result stays + * within the list. `null` for any other key, so the caller leaves it alone. + */ +export function stepIndex(key: string, at: number, count: number): number | null { + const next = + key === "ArrowDown" + ? at + 1 + : key === "ArrowUp" + ? at - 1 + : key === "Home" + ? 0 + : key === "End" + ? count - 1 + : null; + return next === null ? null : Math.max(0, Math.min(count - 1, next)); +} diff --git a/pocs/zeroth-playground/semantics/README.md b/pocs/zeroth-playground/semantics/README.md new file mode 100644 index 00000000..350a1eef --- /dev/null +++ b/pocs/zeroth-playground/semantics/README.md @@ -0,0 +1,81 @@ +# Semantics + +One folder per open question about the semantics of the compilation: what a net means once it is modules, and what Zeroth can express. `register.ts` collects the folders, as `examples/catalog.ts` collects the examples, and the Semantics view of the playground lists them by topic. `intro.mdx` is the view's opening text. + +A question lives here once. An example page references it with ``, and [compiler/mapping.md](../compiler/mapping.md) links its page. Tooling questions, such as executors, seeds and numerics, stay in mapping.md alone. + +## Topics + +| Topic | Holds | +| --- | --- | +| `steps` | what one step of the net means as modules | +| `conflicts` | who decides a choice the net leaves open: the sweep, a pick, a controller, a race between clocks | +| `places` | what a place and its arcs can express, under coins and under clocks | +| `rates` | how a rate becomes a firing, and what SPN's clocks can take | +| `colours` | coloured tokens and continuous dynamics | +| `theories` | modules of two theories in one system | + +`TOPICS` in `register.ts` gives each topic its title and one line on what it is about. + +## One folder per question + +``` +ties-under-clocks/ + meta.ts the title, the topic, the owner, the status, where it shows + page.mdx the prose +``` + +The folder name is the id: the list's key and the URL hash, `#semantics/ties-under-clocks`. `meta.ts` default-exports a `QuestionMeta`: + +| Field | Value | +| --- | --- | +| `title` | the question as a short noun phrase | +| `topic` | one of the topic ids above | +| `order` | its place in its topic; questions sort by it, in steps of 10 | +| `owner` | `"HASH"`, `"Zeroth"` or `"HASH and Zeroth"`: who can settle it | +| `status` | `"open"`, `"proposed"` or `"settled"` | +| `shows` | where it shows: one entry per example, with the options that bring the behaviour out and the net item whose lines quote it | + +Every option in `shows` must apply to that example's net, as `optionsForNet` keeps it, and every item must be a place or transition of the net, or the net itself. `register.test.ts` checks both, and that every question is linked from mapping.md or shown on an example page. + +## Status + +| Status | Meaning | +| --- | --- | +| `open` | nobody has decided | +| `proposed` | one side proposes an answer, written on the page under "Proposal" | +| `settled` | decided; the decision and its date are written on the page under "Decision" | + +Settling a question changes its status here and adds the decision to its page. The example pages and mapping.md only reference it, so they need no change. + +## Page structure + +```mdx +The question in one or two sentences, and why it matters. + +### Today + +What the compiler does now, quoting the Python lines that carry it: + +```python +fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft +``` + +### Options on the table + +- The possible answers, one per bullet. + +### Decision + +Who settled it and when, then what was decided. "Proposal" for a proposed question. +``` + +The view renders a page with the same components as an example page, so fenced code wraps under a hanging indent. Write **Shape** on modular for an option, in bold, and name an example by its feature in italics: *Rates as clocks*. + +## Adding a question + +1. Check that no question covers it. Copy a folder from the same topic and rename it to the new id. +2. Edit `meta.ts`. Pick an `order` after the questions it builds on. +3. Write `page.mdx` as above, quoting the lines from the example's snapshot in `examples/__snapshots__/`. +4. Reference it: `` under `### Open questions` on the example page where it matters most, and a bullet under the mapping's section in compiler/mapping.md. +5. Run `pnpm test`, then open `#semantics/` in `pnpm dev` or the build. diff --git a/pocs/zeroth-playground/semantics/arc-weights-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/arc-weights-under-clocks/meta.ts new file mode 100644 index 00000000..3f998ee4 --- /dev/null +++ b/pocs/zeroth-playground/semantics/arc-weights-under-clocks/meta.ts @@ -0,0 +1,10 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Arc weights under clocks", + topic: "places", + order: 20, + owner: "Zeroth", + status: "open", + shows: [{ example: "arcs", options: { shape: "modular" }, item: { kind: "place", name: "Parts" } }], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/arc-weights-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/arc-weights-under-clocks/page.mdx new file mode 100644 index 00000000..31adfe19 --- /dev/null +++ b/pocs/zeroth-playground/semantics/arc-weights-under-clocks/page.mdx @@ -0,0 +1,17 @@ +How should SPN express an arc of weight 2: `(n + 1) + 1`, an `Inc` of `Inc`, or a count on `Inc` and `Dec`? + +### Today + +Under coins an arc of weight w tests `P >= w` and moves w tokens at once: + +```python +Parts = ite(X(fire_Join), Parts - 2, Parts) # Join takes 2 +``` + +Under clocks an arc of weight above 1 is refused with `clocks-arc-weight`: SPN tests a count against zero and moves one token per arc, `P != 0` and `Pool + 1`. + +### Options on the table + +- Nest the increments, `(n + 1) + 1`, an `Inc` of `Inc`. `check_nat_ops` suggests it types; it is untested. +- A count on `Inc` and `Dec`, so one event moves w tokens. +- Refuse, as today. diff --git a/pocs/zeroth-playground/semantics/capacity-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/capacity-under-clocks/meta.ts new file mode 100644 index 00000000..97b570b7 --- /dev/null +++ b/pocs/zeroth-playground/semantics/capacity-under-clocks/meta.ts @@ -0,0 +1,12 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Capacity under clocks", + topic: "places", + order: 10, + owner: "Zeroth", + status: "open", + shows: [ + { example: "capacity", options: { shape: "modular" }, item: { kind: "place", name: "Buffer" } }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/capacity-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/capacity-under-clocks/page.mdx new file mode 100644 index 00000000..0487b55d --- /dev/null +++ b/pocs/zeroth-playground/semantics/capacity-under-clocks/page.mdx @@ -0,0 +1,18 @@ +How can SPN express a capacity: an ordering on `Nat`, or a complement place? SPN tests a count against zero only. + +### Today + +Under coins a producer tests the fill of the place, and the place applies every mover: + +```python +fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) # PutLeft added 1 +return (RightSource >= 1) & (fill_Buffer + 1 <= 2) +``` + +Under clocks a capacity is refused with `clocks-capacity`, so a stochastic net with a capped place compiles under coins only. + +### Options on the table + +- An ordering on `Nat`, so a producer's clock runs while `Buffer < 2`. +- A complement place of free room, a `Nat` the producers take from and the takers add to. It is one more interface variable per capped place, and it may be empty, which SPN tests. +- Refuse, as today. diff --git a/pocs/zeroth-playground/semantics/coloured-places-as-modules/meta.ts b/pocs/zeroth-playground/semantics/coloured-places-as-modules/meta.ts new file mode 100644 index 00000000..c5de372c --- /dev/null +++ b/pocs/zeroth-playground/semantics/coloured-places-as-modules/meta.ts @@ -0,0 +1,10 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "A coloured place split into modules", + topic: "colours", + order: 10, + owner: "HASH", + status: "open", + shows: [{ example: "bucket", options: { shape: "modular" } }], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/coloured-places-as-modules/page.mdx b/pocs/zeroth-playground/semantics/coloured-places-as-modules/page.mdx new file mode 100644 index 00000000..5e489332 --- /dev/null +++ b/pocs/zeroth-playground/semantics/coloured-places-as-modules/page.mdx @@ -0,0 +1,20 @@ +How should a coloured place split into modules, so that **Shape** on modular applies to every net? The modular shape refuses coloured places and dynamics today. + +### Today + +The modular shape refuses a net with a coloured place or dynamics with `modular-coloured-not-lowered`, before it reads any code. The monolithic step gives each position of a coloured place a present flag and one variable per attribute, and closes the survivors up at the end of the step: + +```python +Pool_0_present = Var(BOOL) # Pool slot 0 holds a token +Pool_0_x = Var(REAL) +rank_Pool_1 = ite(Pool_0_present, expr(1.0, theory=LRA, sort=REAL), 0.0) +Pool_0_present = kept_Pool >= 1.0 +``` + +A transition's bindings read the positions of every input place, and its kernel writes the positions of every output place, so the step is one block of locals. + +### Options on the table + +- One module per place that drives every position's variables, awaiting each transition's selected binding and produced tokens as Bools and Reals in the interface. +- One module per position, so a place of n positions is n modules and the compaction is a chain of awaits. +- Keep the monolithic shape for coloured and dynamic nets. diff --git a/pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/meta.ts new file mode 100644 index 00000000..0fe9eda3 --- /dev/null +++ b/pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/meta.ts @@ -0,0 +1,10 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Coloured tokens under clocks", + topic: "colours", + order: 30, + owner: "Zeroth", + status: "open", + shows: [{ example: "bucket" }], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/page.mdx new file mode 100644 index 00000000..69fd54ea --- /dev/null +++ b/pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/page.mdx @@ -0,0 +1,18 @@ +How are coloured tokens held under SPN: a `Real` sort, or an LRA module beside the SPN ones? Under clocks a place is a `Nat` counter, and an attribute has no sort. + +### Today + +**Rates** on clock does not apply to a coloured net, with the reason "A coloured net, or one with dynamics, compiles under coins only". Under coins each position of a coloured place is a present flag and one `Real` per attribute: + +```python +Pool_0_present = Var(BOOL) # Pool slot 0 holds a token +Pool_0_x = Var(REAL) +``` + +A closed string is a `Real` code, a boolean a `Bool`; a `uuid` or an open string gets no variable. + +### Options on the table + +- A `Real` sort in SPN, so a position's attributes sit beside the counts in one theory. +- An LRA module per coloured place, composed with the SPN modules; see *SPN and LRA modules in one system*. +- A `Nat` counter with the attributes dropped, for nets whose guards and kernels read none. diff --git a/pocs/zeroth-playground/semantics/false-pick-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/false-pick-under-clocks/meta.ts new file mode 100644 index 00000000..9c39c20b --- /dev/null +++ b/pocs/zeroth-playground/semantics/false-pick-under-clocks/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "A false pick under clocks", + topic: "conflicts", + order: 30, + owner: "HASH", + status: "open", + shows: [ + { + example: "conflict-clocked", + options: { rates: "clock", conflicts: "nondet" }, + item: { kind: "transition", name: "TakeLeft" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/false-pick-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/false-pick-under-clocks/page.mdx new file mode 100644 index 00000000..502e4441 --- /dev/null +++ b/pocs/zeroth-playground/semantics/false-pick-under-clocks/page.mdx @@ -0,0 +1,18 @@ +A false pick at expiry stops time. Should the clock pause while its pick is false, or re-arm at expiry whatever the pick? + +### Today + +A transition in a conflict needs its pick when its clock reaches 0, and its `flow` runs the clock down while its input places allow a firing: + +```python +fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft +return ite(clk_TakeLeft >= 0, ite(Pool != 0, -1 * d(t), 0 * d(t)), None), 0 +``` + +With the pick false the clock stays at 0, and `clk_TakeLeft >= 0` holds the flow horizon at 0. In `simulate.py` a round that then changes nothing raises `NoFlow`. No time passes until the pick turns true. + +### Options on the table + +- Hold time, as today: a false pick is a choice to wait, and the driver that holds it false owns the stall. +- Pause the clock in `flow` on the pick, as it pauses on an empty input place: time passes, and the transition fires as soon as the pick turns true. +- Re-arm the clock at expiry whatever the pick: a false pick costs the transition its turn, and the race starts again. diff --git a/pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/meta.ts new file mode 100644 index 00000000..47ba888c --- /dev/null +++ b/pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/meta.ts @@ -0,0 +1,10 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Immediate transitions under clocks", + topic: "rates", + order: 30, + owner: "Zeroth", + status: "open", + shows: [{ example: "cycle", item: { kind: "transition", name: "Go" } }], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/page.mdx new file mode 100644 index 00000000..5c9ce317 --- /dev/null +++ b/pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/page.mdx @@ -0,0 +1,17 @@ +How does a transition without a rate fire under clocks? Under coins it fires every step it is enabled. Under clocks every transition needs a clock, and a clock needs a rate. + +### Today + +Under coins a plain transition is a predicate on the marking, tested once per step: + +```python +fire_Go = A >= 1 # Go: A -> B +``` + +Under clocks a transition without a rate is refused with `clocks-plain-transition`, so a mixed net compiles under coins only. + +### Options on the table + +- A zero-delay construct in SPN, with priorities among the immediate transitions that are enabled at one instant. +- A very high rate as an approximation, which leaves a small chance that a timed transition fires first. +- Refuse, as today. diff --git a/pocs/zeroth-playground/semantics/intro.mdx b/pocs/zeroth-playground/semantics/intro.mdx new file mode 100644 index 00000000..dc408a33 --- /dev/null +++ b/pocs/zeroth-playground/semantics/intro.mdx @@ -0,0 +1,5 @@ +## Semantics + +This view holds what a net means once it is compiled to modules, and what is still open between HASH and Zeroth: what one step is, who settles a choice the net leaves open, what a place can express under clocks, how a rate becomes a firing. Each question quotes the Python the compiler writes today and the answers on the table. Questions about tooling, such as executors and seeds, stay in `compiler/mapping.md`. + +A question names who can settle it, HASH, Zeroth or both, and its status: open, proposed, or settled with a decision and its date. Under it, "Shows in" compiles an example at the options that bring the behaviour out and quotes the lines of the net item that carry it; "Open in Playground" opens that example with those options. diff --git a/pocs/zeroth-playground/semantics/kernels-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/kernels-under-clocks/meta.ts new file mode 100644 index 00000000..034d52fd --- /dev/null +++ b/pocs/zeroth-playground/semantics/kernels-under-clocks/meta.ts @@ -0,0 +1,10 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Kernels under clocks", + topic: "colours", + order: 20, + owner: "Zeroth", + status: "open", + shows: [{ example: "bucket" }], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/kernels-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/kernels-under-clocks/page.mdx new file mode 100644 index 00000000..2012035b --- /dev/null +++ b/pocs/zeroth-playground/semantics/kernels-under-clocks/page.mdx @@ -0,0 +1,18 @@ +How does a kernel run under SPN, which has no sort for a token's attributes? A kernel writes the produced tokens; under clocks a place is a `Nat` counter. + +### Today + +A kernel is refused under clocks with `clocks-kernel`, and **Rates** on clock does not apply to a coloured net at all. Under coins the monolithic step writes each produced attribute as a local and lands it by compaction: + +```python +out_Launch_Airborne_0_battery = ite(sel_Launch_0, Hangar_0_battery, ite(sel_Launch_1, Hangar_1_battery, Hangar_2_battery)) +Airborne_0_battery = ite(fire_Launch & (landed_Airborne == 0.0), out_Launch_Airborne_0_battery, Airborne_0_battery) +``` + +The playground's one coloured example, Bucket, has no kernel, and its rate is code the playground does not parse. + +### Options on the table + +- A `Real` sort in SPN beside `Nat`, so a place keeps its positions and a kernel writes them at the event. +- An LRA module per coloured place for the attributes, beside the SPN modules for the counts; see *SPN and LRA modules in one system*. +- Keep coloured nets under coins. diff --git a/pocs/zeroth-playground/semantics/lra-flow-reads-its-state/meta.ts b/pocs/zeroth-playground/semantics/lra-flow-reads-its-state/meta.ts new file mode 100644 index 00000000..0597aea3 --- /dev/null +++ b/pocs/zeroth-playground/semantics/lra-flow-reads-its-state/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "An LRA flow that reads its own state", + topic: "colours", + order: 40, + owner: "Zeroth", + status: "open", + shows: [ + { + example: "birth-death-clocked", + options: { rates: "clock" }, + item: { kind: "transition", name: "Birth" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/lra-flow-reads-its-state/page.mdx b/pocs/zeroth-playground/semantics/lra-flow-reads-its-state/page.mdx new file mode 100644 index 00000000..0d6d0e26 --- /dev/null +++ b/pocs/zeroth-playground/semantics/lra-flow-reads-its-state/page.mdx @@ -0,0 +1,23 @@ +Will Zeroth's `Drift` generator land, so an LRA flow can read its own state? Dynamics such as `dx/dt = 10 - x` need a flow whose rate depends on the variable it moves. + +### Today + +Under coins the dynamics take one Euler step of `dt` in `next`, before the sweep: + +```python +Tank_0_level = ite(Tank_0_present, Tank_0_level + 0.125 * (10.0 - Tank_0_level), Tank_0_level) # Tank: one Euler step of dt = 0.25 +``` + +An LRA `delay` block was rejected: a `Real` cannot write a tangent, `x * A` raises `NonLinearError`, and `eval.py` runs no delay block. The only flow the compiler writes is a clock's, a constant rate against the time reference: + +```python +return ite(clk_Birth >= 0, -1 * d(t), None), 0 +``` + +**Rates** on clock does not apply to a net with dynamics. + +### Options on the table + +- A `Drift` generator in Zeroth: a flow that reads its own state, so dynamics compile to flows and the net runs in continuous time. +- Keep Euler steps under coins, with `dt` as the step. +- A constant-rate flow per attribute, re-armed at every event, as a piecewise approximation. diff --git a/pocs/zeroth-playground/semantics/marking-dependent-rates/meta.ts b/pocs/zeroth-playground/semantics/marking-dependent-rates/meta.ts new file mode 100644 index 00000000..7089968e --- /dev/null +++ b/pocs/zeroth-playground/semantics/marking-dependent-rates/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Marking-dependent rates under clocks", + topic: "rates", + order: 10, + owner: "Zeroth", + status: "open", + shows: [ + { + example: "birth-death-clocked", + options: { rates: "clock" }, + item: { kind: "transition", name: "Death" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/marking-dependent-rates/page.mdx b/pocs/zeroth-playground/semantics/marking-dependent-rates/page.mdx new file mode 100644 index 00000000..20a0be6a --- /dev/null +++ b/pocs/zeroth-playground/semantics/marking-dependent-rates/page.mdx @@ -0,0 +1,17 @@ +Can SPN take a rate that reads the marking, such as a death rate per member of `Population`? `Exp` takes a constant today. + +### Today + +A clock is armed with a constant, so `Death` fires at rate 1 however many members remain: + +```python +return ite(fires_Death, exp(1.0), clk_Death), ite(fires_Death, ~ev_Death, None) +``` + +Under coins such a rate is written as code, and the compiler tests it against an exponential draw, `rate >= X(e_T)`. This playground passes no code parser, so it refuses the code with `code-not-parsed`. Under clocks the code is refused with `clocks-rate-code` before it is read. + +### Options on the table + +- `Exp` of a term over the marking, re-armed when the marking changes. +- One clock per token: a module per member of `Population`, which the compiler would unroll up to a bound. +- Keep constant rates under clocks and refuse code, as today. diff --git a/pocs/zeroth-playground/semantics/one-mechanism-for-choices/meta.ts b/pocs/zeroth-playground/semantics/one-mechanism-for-choices/meta.ts new file mode 100644 index 00000000..729b785c --- /dev/null +++ b/pocs/zeroth-playground/semantics/one-mechanism-for-choices/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "One mechanism for external choices", + topic: "conflicts", + order: 10, + owner: "HASH", + status: "open", + shows: [ + { + example: "conflict", + options: { shape: "modular", conflicts: "nondet", control: "open" }, + item: { kind: "transition", name: "TakeLeft" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/one-mechanism-for-choices/page.mdx b/pocs/zeroth-playground/semantics/one-mechanism-for-choices/page.mdx new file mode 100644 index 00000000..8ea7346a --- /dev/null +++ b/pocs/zeroth-playground/semantics/one-mechanism-for-choices/page.mdx @@ -0,0 +1,17 @@ +Are **Control** on open and **Conflicts** on nondet one mechanism? Both add an input Bool that nothing drives to a guard, `X(go_T)` or `X(pick_T)`. One option for external choices could replace both. + +### Today + +A controllable transition in a conflict gets both inputs when both options are on. On Conflict, `TakeLeft` is marked `controllable` and shares `Pool` with `TakeRight`: + +```python +return (Pool >= 1) & X(go_TakeLeft) & X(pick_TakeLeft) +``` + +A pick exists for every transition in a conflict; a go exists for every transition the IR marks controllable. The two sets overlap on `TakeLeft` and nowhere else on this net. + +### Options on the table + +- One option for external choices: every undriven Bool is a `choice_T`, on the transitions the net or the IR leaves open. +- Keep two: the IR marks a controller's choice, and the net's shape gives a conflict's pick. A proof over picks ranges over the net's own nondeterminism, a proof over goes over a controller. +- Nest **Control** under **Conflicts**, so open control is one more source of picks. diff --git a/pocs/zeroth-playground/semantics/picks-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/picks-under-clocks/meta.ts new file mode 100644 index 00000000..d66b34b0 --- /dev/null +++ b/pocs/zeroth-playground/semantics/picks-under-clocks/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Picks and choices under clocks", + topic: "conflicts", + order: 50, + owner: "Zeroth", + status: "open", + shows: [ + { + example: "conflict-clocked", + options: { rates: "clock", conflicts: "nondet" }, + item: { kind: "transition", name: "TakeRight" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/picks-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/picks-under-clocks/page.mdx new file mode 100644 index 00000000..5b4027c7 --- /dev/null +++ b/pocs/zeroth-playground/semantics/picks-under-clocks/page.mdx @@ -0,0 +1,18 @@ +How is an input that nothing drives, a pick or a controller's choice, handled under SPN? The sugar has no helper for `Nondet`, and `simulate()` takes no input but `t`. + +### Today + +A pick is a Bool in the interface, read at expiry: + +```python +pick_TakeRight = Var(Bool([1, 1])) # the environment lets TakeRight fire when its clock expires +fires_TakeRight = (clk_TakeRight == 0) & (Pool != 0) & pick_TakeRight +``` + +`simulate.py` drives no external but `t`, so the clocked output under **Conflicts** on nondet does not run there. **Control** on open does not apply under clocks for the same reason: the sugar has no helper for SPN's `Nondet(s)`, and `simulate.py` gives it no semantics. + +### Options on the table + +- A driver for externals in `simulate.py`: a function of the time and the state that writes every undriven Bool each round. +- A `Nondet` helper in the sugar, so a pick is a HAVOC the checker ranges over and the simulator draws. +- A scheduler module the compiler writes, which drives the picks from a policy: record order, or a draw. diff --git a/pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/meta.ts b/pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/meta.ts new file mode 100644 index 00000000..21daf540 --- /dev/null +++ b/pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/meta.ts @@ -0,0 +1,12 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Read and inhibitor arcs in the modular shape", + topic: "places", + order: 30, + owner: "HASH", + status: "settled", + shows: [ + { example: "arcs", options: { shape: "modular" }, item: { kind: "transition", name: "Join" } }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/page.mdx b/pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/page.mdx new file mode 100644 index 00000000..90e78e66 --- /dev/null +++ b/pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/page.mdx @@ -0,0 +1,20 @@ +Does the modular shape test read and inhibitor arcs as the monolithic step does? A read arc tests a place without taking from it, an inhibitor arc needs it below the weight. + +### Today + +Both shapes test the three arcs. On Arcs, `Transition_Join` tests them in one guard, and the places a read or inhibitor arc touches return their count unchanged: + +```python +return (Parts >= 2) & (Power >= 1) & (Jam < 1) +``` + +`lower/lower.test.ts` runs both shapes on random nets with weighted, read and inhibitor arcs and compares every step with the reference step. + +### Options on the table + +- Test them in both shapes. +- Let the modular shape ignore them, which it used to do. + +### Decision + +HASH, 2026-10-02. Both shapes test read and inhibitor arcs. The modular shape used to ignore them; the differential test now covers both arc kinds in both shapes. diff --git a/pocs/zeroth-playground/semantics/read-arcs-as-conflicts/meta.ts b/pocs/zeroth-playground/semantics/read-arcs-as-conflicts/meta.ts new file mode 100644 index 00000000..49405b2e --- /dev/null +++ b/pocs/zeroth-playground/semantics/read-arcs-as-conflicts/meta.ts @@ -0,0 +1,17 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Read and inhibitor arcs as conflicts", + topic: "conflicts", + order: 20, + owner: "HASH", + status: "open", + shows: [ + { example: "arcs", options: { shape: "modular" }, item: { kind: "transition", name: "Join" } }, + { + example: "conflict", + options: { shape: "modular", conflicts: "nondet" }, + item: { kind: "transition", name: "TakeRight" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/read-arcs-as-conflicts/page.mdx b/pocs/zeroth-playground/semantics/read-arcs-as-conflicts/page.mdx new file mode 100644 index 00000000..1557117f --- /dev/null +++ b/pocs/zeroth-playground/semantics/read-arcs-as-conflicts/page.mdx @@ -0,0 +1,18 @@ +Should read and inhibitor arcs count as conflicts? Today any two transitions that share an input place get picks under **Conflicts** on nondet, whatever the arc kinds. So two readers of one place get picks, although neither takes a token. + +### Today + +`conflictingTransitions` groups the transitions by input place and ignores the arc's kind. On Conflict, `TakeLeft` and `TakeRight` take from `Pool` and each gets a pick: + +```python +return (Pool >= 1) & X(pick_TakeLeft) +return (avail_Pool >= 1) & X(pick_TakeRight) +``` + +On Arcs, `Join` reads `Power` and is inhibited by `Jam`, and no other transition touches either place, so **Conflicts** does not apply. Add a second transition that reads `Power`, and both readers get a pick. + +### Options on the table + +- Keep every shared input place as a conflict: a proof under nondet ranges over the readers too. +- Count standard arcs alone: a read or inhibitor arc takes nothing, so two readers never compete for a token. +- Count a read arc against a taker only: a reader and a taker of one place compete for the token's presence, two readers do not. diff --git a/pocs/zeroth-playground/semantics/register.test.ts b/pocs/zeroth-playground/semantics/register.test.ts new file mode 100644 index 00000000..13c3f08d --- /dev/null +++ b/pocs/zeroth-playground/semantics/register.test.ts @@ -0,0 +1,159 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { compile, optionsForNet, parsePetriNetIr, sameItem } from "../compiler"; +import { exampleById, folderOf } from "../examples/catalog"; +import mapping from "../compiler/mapping.md?raw"; +import { QUESTIONS, TOPICS, questionById, questionsOf } from "./register"; + +import type { Showing } from "./register"; + +/** The folders a glob matched, sorted. */ +function foldersOf(matches: Record): string[] { + return Object.keys(matches).map(folderOf).toSorted(); +} + +// The pages are read as text, since the MDX plugin compiles a `?raw` import of an .mdx file too. +const EXAMPLES_DIR = path.resolve(import.meta.dirname, "../examples"); + +/** The ids `` names across the example pages. */ +const REFERENCED_ON_PAGES = readdirSync(EXAMPLES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(path.join(EXAMPLES_DIR, entry.name, "page.mdx"))) + .flatMap((entry) => { + const text = readFileSync(path.join(EXAMPLES_DIR, entry.name, "page.mdx"), "utf8"); + return Array.from(text.matchAll(/ match[1] ?? ""); + }); + +/** The question folders mapping.md links, as `../semantics//page.mdx`. */ +const LINKED_FROM_MAPPING = Array.from( + mapping.matchAll(/\]\(\.\.\/semantics\/([^/)]+)\/page\.mdx\)/gu), + (match) => match[1] ?? "", +); + +/** Each showing with its example, compiled at its options; throws on an example the catalog lacks. */ +function placed(showing: Showing) { + const example = exampleById(showing.example); + if (example === undefined) { + throw new Error(`no example ${showing.example}`); + } + const parsed = parsePetriNetIr(example.ir); + if (!parsed.ok) { + throw new Error(`${showing.example} does not parse`); + } + return { example, ir: parsed.ir, compilation: compile(example.ir, { options: showing.options }) }; +} + +describe("the question folders", () => { + it("each hold a meta.ts and a page.mdx, and each is a question", () => { + // GIVEN the folders that hold a meta.ts + const metas = foldersOf(import.meta.glob("./*/meta.ts")); + // THEN the same folders hold a page.mdx, and each is a question + expect(foldersOf(import.meta.glob("./*/page.mdx"))).toEqual(metas); + expect(QUESTIONS.map((question) => question.id).toSorted()).toEqual(metas); + }); + + it("have unique ids, and unique orders within a topic", () => { + // GIVEN every question + // THEN no id repeats, and no two questions of one topic share an order + expect(new Set(QUESTIONS.map((question) => question.id)).size).toBe(QUESTIONS.length); + for (const topic of TOPICS) { + const orders = questionsOf(topic.id).map((question) => question.order); + expect(new Set(orders).size, topic.id).toBe(orders.length); + } + }); + + it("sort by topic in the topics' order, then by order", () => { + // GIVEN the questions as the register lists them + const ranks = TOPICS.map((topic) => topic.id); + // WHEN each is placed by topic + const placed = QUESTIONS.map((question) => ranks.indexOf(question.topic)); + // THEN the topics never go back, and within a topic the orders rise + expect(placed).toEqual(placed.toSorted((a, b) => a - b)); + for (const topic of TOPICS) { + const orders = questionsOf(topic.id).map((question) => question.order); + expect(orders).toEqual(orders.toSorted((a, b) => a - b)); + } + }); + + it("give every topic at least one question", () => { + // GIVEN every topic + // THEN each has a question + for (const topic of TOPICS) { + expect(questionsOf(topic.id).length, topic.id).toBeGreaterThan(0); + } + }); +}); + +describe("where the questions show", () => { + const showings = QUESTIONS.flatMap((question) => + question.shows.map((showing) => ({ question, showing })), + ); + + it("name an example of the catalog, compiled under options the panel keeps for its net", () => { + // GIVEN every showing + for (const { question, showing } of showings) { + // WHEN its example is compiled at its options + const { ir } = placed(showing); + // THEN the options are the ones optionsForNet keeps for that net, so the panel shows them + expect(optionsForNet(showing.options, ir), `${question.id} on ${showing.example}`).toEqual( + showing.options ?? {}, + ); + } + }); + + it("name a place, a transition or the net of the example", () => { + // GIVEN every showing with an item + for (const { question, showing } of showings) { + if (showing.item === undefined) { + continue; + } + const { ir } = placed(showing); + // THEN the item is in the net + const names = + showing.item.kind === "place" + ? Object.keys(ir.places) + : showing.item.kind === "transition" + ? Object.keys(ir.transitions) + : showing.item.kind === "net" + ? [ir.name] + : []; + expect(names, `${question.id} on ${showing.example}`).toContain(showing.item.name); + } + }); + + it("quote lines of the item when the example compiles", () => { + // GIVEN every showing with an item whose example compiles + for (const { question, showing } of showings) { + const { compilation } = placed(showing); + const file = compilation.files[0]; + const { item } = showing; + if (item === undefined || file === undefined) { + continue; + } + // THEN the file traces lines to the item + const traced = file.trace.some( + ({ provenance }) => provenance.source !== undefined && sameItem(provenance.source, item), + ); + expect(traced, `${question.id} on ${showing.example}`).toBe(true); + } + }); +}); + +describe("the references to the questions", () => { + it("resolve: every id an example page or mapping.md names is a question", () => { + // GIVEN the ids the example pages and mapping.md reference + // THEN each names a question of the register + for (const id of [...REFERENCED_ON_PAGES, ...LINKED_FROM_MAPPING]) { + expect(questionById(id), id).toBeDefined(); + } + }); + + it("leave no question orphaned: each is linked from mapping.md or shown on an example page", () => { + // GIVEN every question + const referenced = new Set([...REFERENCED_ON_PAGES, ...LINKED_FROM_MAPPING]); + // THEN something references it + const orphans = QUESTIONS.filter((question) => !referenced.has(question.id)).map((q) => q.id); + expect(orphans).toEqual([]); + }); +}); diff --git a/pocs/zeroth-playground/semantics/register.ts b/pocs/zeroth-playground/semantics/register.ts new file mode 100644 index 00000000..050b2b9f --- /dev/null +++ b/pocs/zeroth-playground/semantics/register.ts @@ -0,0 +1,119 @@ +import { folderOf } from "../examples/catalog"; + +import type { CompilerOptions, NetItem } from "../compiler"; + +/** + * The register of questions about the semantics of the compilation: what a + * net means once it is modules, and what Zeroth can express. One folder per + * question, collected here as the example catalog collects the examples. The + * example pages and compiler/mapping.md reference a question; its text lives + * here once. + */ + +export type TopicId = "steps" | "conflicts" | "places" | "rates" | "colours" | "theories"; + +export type Topic = { + id: TopicId; + /** The topic's heading in the list. */ + title: string; + /** One line on what its questions are about. */ + about: string; +}; + +/** The topics, in the order the list shows them. */ +export const TOPICS: readonly Topic[] = [ + { + id: "steps", + title: "One step", + about: "What one step of the net means as modules: what a transition sees of the marking, and whether two firings share a step.", + }, + { + id: "conflicts", + title: "Conflicts and choices", + about: "Who decides a choice the net leaves open: the sweep, a pick, a controller, or a race between clocks.", + }, + { + id: "places", + title: "Places and arcs", + about: "What a place and its arcs can express: a capacity, a weight above one, a read or inhibitor arc, under coins and under clocks.", + }, + { + id: "rates", + title: "Rates and clocks", + about: "How a rate becomes a firing: a coin per step, or a clock in continuous time, and what SPN's clocks can take.", + }, + { + id: "colours", + title: "Colours and dynamics", + about: "Coloured tokens and continuous dynamics: how they split into modules, and what SPN has no sort for.", + }, + { + id: "theories", + title: "Composing theories", + about: "How modules of two theories meet in one system.", + }, +]; + +/** Who can settle a question. */ +export type Owner = "HASH" | "Zeroth" | "HASH and Zeroth"; + +/** + * `open`: nobody has decided. `proposed`: one side proposes an answer, written + * on the page under "Proposal". `settled`: decided, the decision and its date + * written under "Decision". + */ +export type Status = "open" | "proposed" | "settled"; + +/** + * Where a question shows: an example compiled at the options that bring the + * behaviour out, and the net item whose lines quote it. Without an item the + * first lines of the file are quoted. + */ +export type Showing = { + example: string; + options?: CompilerOptions; + item?: NetItem; +}; + +/** What a question's `meta.ts` declares. */ +export type QuestionMeta = { + title: string; + topic: TopicId; + /** Its place in its topic: questions sort by it, lowest first. */ + order: number; + owner: Owner; + status: Status; + shows: readonly Showing[]; +}; + +/** A question as the app reads it: its folder's metadata, and the folder's name as its id. */ +export type Question = QuestionMeta & { id: string }; + +// One folder per question: `meta.ts` and `page.mdx`. +const METAS = import.meta.glob("./*/meta.ts", { eager: true, import: "default" }); + +const TOPIC_RANK = new Map(TOPICS.map((topic, index) => [topic.id, index])); + +/** Every question, by topic in the topics' order, then by `order`. */ +export const QUESTIONS: readonly Question[] = Object.entries(METAS) + .map(([path, meta]): Question => ({ ...meta, id: folderOf(path) })) + .toSorted( + (a, b) => + (TOPIC_RANK.get(a.topic) ?? 0) - (TOPIC_RANK.get(b.topic) ?? 0) || a.order - b.order, + ); + +export function questionById(id: string): Question | undefined { + return QUESTIONS.find((question) => question.id === id); +} + +export function questionsOf(topic: TopicId): Question[] { + return QUESTIONS.filter((question) => question.topic === topic); +} + +export function topicById(id: TopicId): Topic { + const topic = TOPICS.find((candidate) => candidate.id === id); + if (topic === undefined) { + throw new Error(`no topic ${id}`); + } + return topic; +} diff --git a/pocs/zeroth-playground/semantics/room-freed-later-in-the-step/meta.ts b/pocs/zeroth-playground/semantics/room-freed-later-in-the-step/meta.ts new file mode 100644 index 00000000..a0554f06 --- /dev/null +++ b/pocs/zeroth-playground/semantics/room-freed-later-in-the-step/meta.ts @@ -0,0 +1,12 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Room freed later in the step", + topic: "steps", + order: 10, + owner: "HASH", + status: "open", + shows: [ + { example: "capacity", options: { shape: "modular" }, item: { kind: "transition", name: "PutRight" } }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/room-freed-later-in-the-step/page.mdx b/pocs/zeroth-playground/semantics/room-freed-later-in-the-step/page.mdx new file mode 100644 index 00000000..450b2b17 --- /dev/null +++ b/pocs/zeroth-playground/semantics/room-freed-later-in-the-step/page.mdx @@ -0,0 +1,19 @@ +Should a capacity test count the room a later transition frees in the same step? A producer of a capped place sees the tokens the producers before it will land, and nothing a taker after it will remove. + +### Today + +A producer rebuilds the fill of the place from the flags of the earlier movers in record order. On Capacity, `PutRight` counts `PutLeft` and not `Take`: + +```python +fill_Buffer = Buffer +fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) # PutLeft added 1 +return (RightSource >= 1) & (fill_Buffer + 1 <= 2) +``` + +`Take` comes after both producers, so the room it frees counts from the next step. A full `Buffer` that `Take` drains in this step still blocks both producers. + +### Options on the table + +- Keep record order: a producer sees only what the sweep decided before it. One step can leave a producer blocked by a place that is empty at the end of the step. +- Count the takers too: a producer awaits the flag of every taker of the place. A taker that itself reads a flag of the producer would close a cycle in the awaits, which must form a DAG. +- Put the takers of a capped place before its producers in the sweep, whatever the record order. diff --git a/pocs/zeroth-playground/semantics/spn-and-lra-in-one-system/meta.ts b/pocs/zeroth-playground/semantics/spn-and-lra-in-one-system/meta.ts new file mode 100644 index 00000000..9f73c98b --- /dev/null +++ b/pocs/zeroth-playground/semantics/spn-and-lra-in-one-system/meta.ts @@ -0,0 +1,17 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "SPN and LRA modules in one system", + topic: "theories", + order: 10, + owner: "Zeroth", + status: "open", + shows: [ + { example: "cafe-queue", options: { rates: "clock" }, item: { kind: "net", name: "cafe_queue" } }, + { + example: "birth-death", + options: { shape: "modular", marking: "int", dt: 0.5 }, + item: { kind: "transition", name: "Birth" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/spn-and-lra-in-one-system/page.mdx b/pocs/zeroth-playground/semantics/spn-and-lra-in-one-system/page.mdx new file mode 100644 index 00000000..a8d3851c --- /dev/null +++ b/pocs/zeroth-playground/semantics/spn-and-lra-in-one-system/page.mdx @@ -0,0 +1,19 @@ +Do SPN and LRA modules compose in one system? Clocked arrivals beside a coin model of the service would need it, and so would coloured tokens under clocks. + +### Today + +Every module of a clocked net is in SPN, and the system composes them with the clocks hidden: + +```python +transition_Arrive = Transition_Arrive(theory=SPN, ctrl=(clk_Arrive, ev_Arrive), extl=(t,)) +net = compose( + transition_Arrive, +``` + +Under coins, **Marking** on Int already composes two theories: one LRA `Draw_T` module per rated transition turns its draw into a Bool flag, and the LIA modules read it through `X(hit_T)`. Whether SPN joins such a composition is Zeroth's to say. + +### Options on the table + +- `compose` takes modules of any theories, and a variable's sort fixes which theory reads it. +- A bridge module that reads an SPN event and drives a linear Bool, or the reverse. +- One theory per system, as today under clocks. diff --git a/pocs/zeroth-playground/semantics/ties-under-clocks/meta.ts b/pocs/zeroth-playground/semantics/ties-under-clocks/meta.ts new file mode 100644 index 00000000..e52393c9 --- /dev/null +++ b/pocs/zeroth-playground/semantics/ties-under-clocks/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Ties under clocks", + topic: "conflicts", + order: 40, + owner: "HASH and Zeroth", + status: "open", + shows: [ + { + example: "conflict-clocked", + options: { rates: "clock", conflicts: "nondet" }, + item: { kind: "place", name: "Pool" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/ties-under-clocks/page.mdx b/pocs/zeroth-playground/semantics/ties-under-clocks/page.mdx new file mode 100644 index 00000000..89bb1bd0 --- /dev/null +++ b/pocs/zeroth-playground/semantics/ties-under-clocks/page.mdx @@ -0,0 +1,17 @@ +Two takers that expire together both produce, and `Pool` loses no token. Under exponential clocks a tie has chance zero, so this matters only where delays can be equal. Should `Place_Pool` apply both takes on a tie, or should a tie fire one transition? + +### Today + +Each case of the place needs the other events off, so a step where two events toggle leaves the count where it was: + +```python +return ite(fired_Return & ~fired_TakeLeft & ~fired_TakeRight, Pool + 1, ite(fired_TakeLeft & ~fired_Return & ~fired_TakeRight & (Pool != 0), Pool - 1, ite(fired_TakeRight & ~fired_Return & ~fired_TakeLeft & (Pool != 0), Pool - 1, Pool))) +``` + +`Left` and `Right` each gain a token meanwhile. Zeroth's `test_birth_death_over_a_long_run` covers a producer and a consumer only, and `_breakpoint` in `simulate.py` pins only the expiring clock. + +### Options on the table + +- Apply every take on a tie: the place needs `Pool >= 2` for two takes, and a tie that it cannot afford fires neither. +- Fire one transition per instant, the earlier in record order; the other keeps its clock at 0 and fires in the next round. +- Leave it: a tie has chance zero under exponential clocks. diff --git a/pocs/zeroth-playground/semantics/ties-under-coins/meta.ts b/pocs/zeroth-playground/semantics/ties-under-coins/meta.ts new file mode 100644 index 00000000..6d39ed03 --- /dev/null +++ b/pocs/zeroth-playground/semantics/ties-under-coins/meta.ts @@ -0,0 +1,21 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "Ties under coins", + topic: "steps", + order: 20, + owner: "HASH and Zeroth", + status: "open", + shows: [ + { + example: "birth-death", + options: { shape: "modular", dt: 0.5 }, + item: { kind: "place", name: "Population" }, + }, + { + example: "birth-death-clocked", + options: { rates: "clock" }, + item: { kind: "place", name: "Population" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/ties-under-coins/page.mdx b/pocs/zeroth-playground/semantics/ties-under-coins/page.mdx new file mode 100644 index 00000000..87b819d5 --- /dev/null +++ b/pocs/zeroth-playground/semantics/ties-under-coins/page.mdx @@ -0,0 +1,24 @@ +Should a coin step allow ties? In one step two transitions can both fire, and the place applies both. Under clocks two expiries at one instant have chance zero, and the place applies neither. + +### Today + +Under coins `Place_Population` applies `Birth` and `Death` one after the other, so a step where both fire leaves the count where it was: + +```python +Population = ite(X(fire_Birth), Population + 1.0, Population) # Birth adds 1 +Population = ite(X(fire_Death), Population - 1.0, Population) # Death takes 1 +``` + +Under clocks each case of the place needs the other event off: + +```python +return ite(fired_Birth & ~fired_Death, Population + 1, ite(fired_Death & ~fired_Birth & (Population != 0), Population - 1, Population)) +``` + +Both rules leave the same marking on Birth–death. They differ where one firing disables another by adding a token, as through an inhibitor arc: the coin step fires both, and clocks fire one. + +### Options on the table + +- Keep both: a coin step is a sweep, a clocked step is one event. +- Fire at most one transition per coin step, so the step is a race as under clocks. Rates then shrink with the number of enabled transitions. +- Let a clocked place apply every take on a tie, as *Ties under clocks* asks. diff --git a/pocs/zeroth-playground/semantics/transition-or-places-decide/meta.ts b/pocs/zeroth-playground/semantics/transition-or-places-decide/meta.ts new file mode 100644 index 00000000..b66f6529 --- /dev/null +++ b/pocs/zeroth-playground/semantics/transition-or-places-decide/meta.ts @@ -0,0 +1,16 @@ +import type { QuestionMeta } from "../register"; + +export default { + title: "The transition decides, or the places decide", + topic: "rates", + order: 20, + owner: "HASH and Zeroth", + status: "open", + shows: [ + { + example: "cafe-queue", + options: { rates: "clock" }, + item: { kind: "transition", name: "BeginService" }, + }, + ], +} satisfies QuestionMeta; diff --git a/pocs/zeroth-playground/semantics/transition-or-places-decide/page.mdx b/pocs/zeroth-playground/semantics/transition-or-places-decide/page.mdx new file mode 100644 index 00000000..08f39e15 --- /dev/null +++ b/pocs/zeroth-playground/semantics/transition-or-places-decide/page.mdx @@ -0,0 +1,18 @@ +Under clocks, should the transition decide, as `fires_BeginService` does, or the places, as in Zeroth's `birth_death.py`? Under the places' rule `BeginService` can fire with `FreeStaff` empty, so the staff, `FreeStaff + Serving`, can grow past the two it starts with. + +### Today + +The transition decides. It keeps the test of every input place, and its clock runs down only while all of them hold a token: + +```python +fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) +return ite(clk_BeginService >= 0, ite((Waiting != 0) & (FreeStaff != 0), -1 * d(t), 0 * d(t)), None), 0 +``` + +Zeroth's `birth_death.py` at 5bd3bf9 lets the clock run, and the place ignores a death on an empty place. With one input place the two rules agree, since an exponential delay has no memory. With two they do not. + +### Options on the table + +- The transition decides, as today: a firing takes one token from each input place or none. +- The places decide, as in `birth_death.py`: `fires = (clk == 0)`, and each place gives a token if it has one. +- Both, as an option, so a net written for Zeroth's rule compiles to it. diff --git a/pocs/zeroth-playground/tsconfig.json b/pocs/zeroth-playground/tsconfig.json index fde5a78d..c79c2f19 100644 --- a/pocs/zeroth-playground/tsconfig.json +++ b/pocs/zeroth-playground/tsconfig.json @@ -18,5 +18,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["compiler", "examples", "playground", "vite.config.ts"] + "include": ["compiler", "examples", "semantics", "playground", "vite.config.ts"] } diff --git a/pocs/zeroth-playground/vite.config.ts b/pocs/zeroth-playground/vite.config.ts index b9ce0316..ea5b7ee3 100644 --- a/pocs/zeroth-playground/vite.config.ts +++ b/pocs/zeroth-playground/vite.config.ts @@ -64,6 +64,6 @@ export default defineConfig({ test: { // Vitest's root defaults to Vite's root, playground/: widen it to the project. root: fromRoot("."), - include: ["{compiler,examples,playground}/**/*.test.{ts,tsx}"], + include: ["{compiler,examples,semantics,playground}/**/*.test.{ts,tsx}"], }, }); From fdd5a46f33a379d79bb4c21f391179cdabec353a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 2 Oct 2026 12:22:58 +0200 Subject: [PATCH 07/20] Rename the Examples view to Playground The first segment of the view switch reads Playground, and the view behind it is playground/playground-view/. "Examples" named the picker's list, which it keeps, and misnamed the workspace: it is where the IR, the options and the module are edited, whatever example they started from. - View id "examples" becomes "playground", ExamplesView PlaygroundView, and the folder and its two files move with git mv. The hash scheme is unchanged: # opens the Playground view on that example. - The comments, the stage texts, the Compiler guide and the READMEs that named the Examples view now name the Playground view. Checked headlessly on the build: the three segments switch, each view shows its own panels, the picker opens another example and its page follows, and a question's Open in Playground lands in the Playground view with its options. --- pocs/zeroth-playground/playground/README.md | 12 ++++++------ pocs/zeroth-playground/playground/app/app.tsx | 10 +++++----- .../playground/app/document.test.ts | 2 +- pocs/zeroth-playground/playground/app/document.ts | 2 +- pocs/zeroth-playground/playground/app/navigation.ts | 2 +- pocs/zeroth-playground/playground/app/route.test.ts | 12 ++++++------ pocs/zeroth-playground/playground/app/route.ts | 6 +++--- .../zeroth-playground/playground/app/view-switch.tsx | 2 +- .../playground/compiler-view/compiler-guide.mdx | 4 ++-- .../playground/compiler-view/compiler-view.tsx | 2 +- .../playground/compiler-view/stages.ts | 4 ++-- .../compiler-options-panel.css | 0 .../compiler-options-panel.tsx | 0 .../diagnostics-list.css | 0 .../diagnostics-list.tsx | 0 .../{examples-view => playground-view}/file-list.css | 0 .../{examples-view => playground-view}/file-list.tsx | 0 .../{examples-view => playground-view}/hover.ts | 0 .../module-edits.test.ts | 0 .../module-edits.ts | 0 .../module-view.css | 0 .../module-view.tsx | 0 .../playground-view.css} | 0 .../playground-view.tsx} | 8 ++++---- .../playground/semantics-view/semantics-view.tsx | 2 +- .../playground/semantics-view/showing-view.tsx | 4 ++-- 26 files changed, 36 insertions(+), 36 deletions(-) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/compiler-options-panel.css (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/compiler-options-panel.tsx (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/diagnostics-list.css (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/diagnostics-list.tsx (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/file-list.css (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/file-list.tsx (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/hover.ts (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/module-edits.test.ts (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/module-edits.ts (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/module-view.css (100%) rename pocs/zeroth-playground/playground/{examples-view => playground-view}/module-view.tsx (100%) rename pocs/zeroth-playground/playground/{examples-view/examples-view.css => playground-view/playground-view.css} (100%) rename pocs/zeroth-playground/playground/{examples-view/examples-view.tsx => playground-view/playground-view.tsx} (96%) diff --git a/pocs/zeroth-playground/playground/README.md b/pocs/zeroth-playground/playground/README.md index ca1416d5..22919b72 100644 --- a/pocs/zeroth-playground/playground/README.md +++ b/pocs/zeroth-playground/playground/README.md @@ -11,7 +11,7 @@ The source sits in four folders at the root. `vite.config.ts` sets Vite's root t | Folder | Holds | | --- | --- | | `app/` | the shell: `App`, the URL route, the open document and its edits, the header with the view switch and the example picker | -| `examples-view/` | the Examples view: its grid, the module view with its file list and diagnostics, the compiler options panel, hover state, module edits | +| `playground-view/` | the Playground view: its grid, the module view with its file list and diagnostics, the compiler options panel, hover state, module edits | | `compiler-view/` | the Compiler view: the guide, the pipeline graph, the stage list, the stage cards and their live samples, the options table | | `semantics-view/` | the Semantics view: the question list by topic, the question card with its page and where it shows, the evidence compiled off the catalog | | `options/` | how the compiler options read and change in the playground, shared by both views | @@ -51,16 +51,16 @@ NavigationContext { openQuestion, openExample } ``` - `App` in `app/app.tsx` holds the document and compiles it during render. `app/route.ts` reads the URL hash it opens on, and `app/document.ts` holds the document's pure transitions. -- `ExamplesView` takes the document and hands back the next one, built with those transitions. `CompilerView` only reads it. +- `PlaygroundView` takes the document and hands back the next one, built with those transitions. `CompilerView` only reads it. - The IR carries no options: the options panel holds them. -- A new IR text or new options drop the module edits. Reset and switching examples rebuild the document from the catalog. +- A new IR text or new options drop the module edits. Reset and switching examples rebuild the document from the catalog. `#` opens the Playground view on that example. - All three views stay mounted under React's `Activity`, so each keeps its panel sizes, collapsed panels and pins. - The Semantics view reads the register and the catalog alone, never the document. `App` holds the selected question, so a page's `Question` card can open it there through `app/navigation.ts`. - The playground passes no code parser, so the nets with code strings are refused with `code-not-parsed`. ## Layout -- The Examples view is a `react-resizable-panels` grid: the documentation as a full-height first column, the IR and the module side by side, the net preview under them. Dragging a gridline resizes a row or a column, and dragging the point where two cross resizes both. A hovered or dragged gridline shows a blue overlay with a deep-blue line. +- The Playground view is a `react-resizable-panels` grid: the documentation as a full-height first column, the IR and the module side by side, the net preview under them. Dragging a gridline resizes a row or a column, and dragging the point where two cross resizes both. A hovered or dragged gridline shows a blue overlay with a deep-blue line. - Every view is the same `Panel`. The documentation, the compiler options, the preview and the files collapse from the chevron in their head; collapsed against a side, a panel becomes a strip with its title written upright. - The example picker is a popover anchored to its button and walked with the arrow keys. It lists the listed examples rung by rung and skips an empty rung; an unlisted example appears while it is open. - `NetPreview` is the one component that takes a `PetriNetIr`, the boundary a canvas can replace. @@ -87,7 +87,7 @@ The panel stores only what `optionsForNet` keeps: the options that apply to the ## The Semantics view -`semantics/register.ts` lists the questions by topic; [semantics/README.md](../semantics/README.md) says how to add one. The view's left panel is the list, grouped by `TOPICS`, with a status dot per row. The right panel shows the intro and the topics with their counts, or the selected question's card: its page, rendered with the same components as an example page, then "Shows in". Each showing compiles its example at its options with `compile`, independent of the open document, and `semantics-view/evidence.ts` quotes the lines `linesOfItem` gives for the net item, up to 14, or the first diagnostic when the compile refuses. "Open in Playground" opens the example in the Examples view with those options. `#semantics` opens the view, `#semantics/` a question. +`semantics/register.ts` lists the questions by topic; [semantics/README.md](../semantics/README.md) says how to add one. The view's left panel is the list, grouped by `TOPICS`, with a status dot per row. The right panel shows the intro and the topics with their counts, or the selected question's card: its page, rendered with the same components as an example page, then "Shows in". Each showing compiles its example at its options with `compile`, independent of the open document, and `semantics-view/evidence.ts` quotes the lines `linesOfItem` gives for the net item, up to 14, or the first diagnostic when the compile refuses. "Open in Playground" opens the example in the Playground view with those options. `#semantics` opens the view, `#semantics/` a question. ## Conventions @@ -96,7 +96,7 @@ The panel stores only what `optionsForNet` keeps: the options that apply to the - The React Compiler memoizes, through `@vitejs/plugin-react`'s `compiler` option with `oxc-transform-react`. Derive values in render and do not add `useMemo` or `useCallback`. - Every React Compiler diagnostic fails `pnpm build`, the dev server's module and the tests that import it: a ref read in render, `setState` in render, an effect that only derives state, a construct the compiler cannot memoize. `vite.config.ts` sets `panicThreshold: "all_errors"` and the stricter checks. Restructure the component rather than opting out. `"use no memo"` still compiles the function, and a diagnostic inside it is only logged. - Effects only synchronize systems React does not own. Today that is Monaco, in `editor/monaco-editor.tsx`. -- State is plain data with pure transition functions beside it, tested without the DOM. `app/document.ts`, `examples-view/module-edits.ts` and `pointer/pointer-motion.ts` are examples. +- State is plain data with pure transition functions beside it, tested without the DOM. `app/document.ts`, `playground-view/module-edits.ts` and `pointer/pointer-motion.ts` are examples. - Comments say what the code does and why. Prose follows the house style: plain words, short sentences, no em dashes. ## Styles diff --git a/pocs/zeroth-playground/playground/app/app.tsx b/pocs/zeroth-playground/playground/app/app.tsx index 301a5e30..607c1d9a 100644 --- a/pocs/zeroth-playground/playground/app/app.tsx +++ b/pocs/zeroth-playground/playground/app/app.tsx @@ -3,7 +3,7 @@ import { Activity, useState } from "react"; import { compile } from "../../compiler"; import { FIRST_EXAMPLE, exampleById } from "../../examples/catalog"; import { CompilerView } from "../compiler-view/compiler-view"; -import { ExamplesView } from "../examples-view/examples-view"; +import { PlaygroundView } from "../playground-view/playground-view"; import { useDwell } from "../pointer/use-dwell"; import { SemanticsView } from "../semantics-view/semantics-view"; import { type Document, documentOf, isChanged } from "./document"; @@ -12,7 +12,7 @@ import { type Navigation, NavigationContext } from "./navigation"; import { type View, routeOf } from "./route"; import type { CompilerOptions } from "../../compiler"; -import type { Hover } from "../examples-view/hover"; +import type { Hover } from "../playground-view/hover"; import "./app.css"; @@ -50,7 +50,7 @@ export const App: React.FC = () => { }, openExample: (id, options) => { selectExample(id, options); - setView("examples"); + setView("playground"); }, }; @@ -75,9 +75,9 @@ export const App: React.FC = () => { /> - +
    - { - it("opens the example a hash names in the Examples view", () => { + it("opens the example a hash names in the Playground view", () => { // GIVEN the hash of an example // WHEN it is read const route = routeOf("#birth-death"); - // THEN the Examples view opens on that example - expect(route.view).toBe("examples"); + // THEN the Playground view opens on that example + expect(route.view).toBe("playground"); expect(route.example.id).toBe("birth-death"); }); @@ -41,8 +41,8 @@ describe("routeOf", () => { it("opens the first example for an empty hash or one the catalog does not hold", () => { // GIVEN no hash, and a hash naming no example - // THEN each opens the first example in the Examples view - expect(routeOf("")).toEqual({ view: "examples", example: FIRST_EXAMPLE }); - expect(routeOf("#no-such-example")).toEqual({ view: "examples", example: FIRST_EXAMPLE }); + // THEN each opens the first example in the Playground view + expect(routeOf("")).toEqual({ view: "playground", example: FIRST_EXAMPLE }); + expect(routeOf("#no-such-example")).toEqual({ view: "playground", example: FIRST_EXAMPLE }); }); }); diff --git a/pocs/zeroth-playground/playground/app/route.ts b/pocs/zeroth-playground/playground/app/route.ts index 2b90c126..4c6b596f 100644 --- a/pocs/zeroth-playground/playground/app/route.ts +++ b/pocs/zeroth-playground/playground/app/route.ts @@ -1,8 +1,8 @@ import { type Example, FIRST_EXAMPLE, exampleById } from "../../examples/catalog"; import { type Question, questionById } from "../../semantics/register"; -/** The three views: the examples workspace, how the compiler works, and the questions on the semantics. */ -export type View = "examples" | "compiler" | "semantics"; +/** The three views: the Playground, how the compiler works, and the questions on the semantics. */ +export type View = "playground" | "compiler" | "semantics"; /** What the playground opens on; `question` only when the hash selects one in the Semantics view. */ export type Route = { view: View; example: Example; question?: Question }; @@ -23,5 +23,5 @@ export function routeOf(hash: string): Route { const question = questionById(second); return { view: "semantics", example: FIRST_EXAMPLE, ...(question === undefined ? {} : { question }) }; } - return { view: "examples", example: exampleById(first) ?? FIRST_EXAMPLE }; + return { view: "playground", example: exampleById(first) ?? FIRST_EXAMPLE }; } diff --git a/pocs/zeroth-playground/playground/app/view-switch.tsx b/pocs/zeroth-playground/playground/app/view-switch.tsx index 97f48b2f..658e5598 100644 --- a/pocs/zeroth-playground/playground/app/view-switch.tsx +++ b/pocs/zeroth-playground/playground/app/view-switch.tsx @@ -3,7 +3,7 @@ import type { View } from "./route"; import "./view-switch.css"; const VIEWS: readonly { id: View; label: string }[] = [ - { id: "examples", label: "Examples" }, + { id: "playground", label: "Playground" }, { id: "compiler", label: "Compiler" }, { id: "semantics", label: "Semantics" }, ]; diff --git a/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx b/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx index 38884d03..87d633d0 100644 --- a/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx +++ b/pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx @@ -6,11 +6,11 @@ import { StrategyTable } from "./strategy-table"; This view shows how the playground turns the IR text into Python, one part at a time. The graph holds every part. Rounded boxes are documents, square boxes are steps, and dashed boxes are inputs beside the main line. A notch in a corner marks what the playground adds around the compiler. -Hover a box, or a stage named in this guide, to open its card under the graph. Click to pin it, and click again to let it go. Beside each card, a Live section shows what the stage made of the example picked in the header. Pick another example in the header, or edit the text or the compiler options in the Examples view, and the cards follow. +Hover a box, or a stage named in this guide, to open its card under the graph. Click to pin it, and click again to let it go. Beside each card, a Live section shows what the stage made of the example picked in the header. Pick another example in the header, or edit the text or the compiler options in the Playground view, and the cards follow. ### The stages -1. The IR text is the YAML in the IR editor of the Examples view. It carries no compiler options. +1. The IR text is the YAML in the IR editor of the Playground view. It carries no compiler options. 2. Parse reads it with js-yaml, then checks it against a strict zod schema. The result is a PetriNetIr, or diagnostics with their lines. 3. The compiler options come from the panel under the IR editor. The compile passes them beside the parsed document, to the lowering and the traces; the text never holds them. 4. Lower calls `lowerPetriNetIr(ir, options, parseCode)`. No code parser is passed, so a net with code strings is refused with `code-not-parsed`. diff --git a/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx b/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx index 2bdd12e2..6fb977c7 100644 --- a/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx +++ b/pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx @@ -26,7 +26,7 @@ const FOLDS = ["guide"] as const; * How the compiler works: the guide on the left, and on the right the graph * of every stage over the card of the focused one, read off the example in * the editor. A hover focuses a stage once the pointer settles on it, as in - * the examples view; a keyboard focus focuses it at once, and a click pins + * the Playground view; a keyboard focus focuses it at once, and a click pins * it. The card follows the hovered stage, else the keyboard's, else the * pinned one, else lists them all. Every stage's sample is read once per render, * off the example: its title, its IR text, its options and what it compiles to. diff --git a/pocs/zeroth-playground/playground/compiler-view/stages.ts b/pocs/zeroth-playground/playground/compiler-view/stages.ts index d400ef2b..295de37f 100644 --- a/pocs/zeroth-playground/playground/compiler-view/stages.ts +++ b/pocs/zeroth-playground/playground/compiler-view/stages.ts @@ -55,7 +55,7 @@ export const STAGES: readonly Stage[] = [ label: "IR text", note: "YAML", title: "IR text", - summary: "The Petri net IR as block-style YAML: the text the IR editor of the Examples view holds.", + summary: "The Petri net IR as block-style YAML: the text the IR editor of the Playground view holds.", call: "irText: string", input: "An example, then the editor", output: "`parse`", @@ -204,7 +204,7 @@ export const STAGES: readonly Stage[] = [ label: "Python files", note: "net.py first", title: "Python files", - summary: "The emitted Python, `net.py` first. The Python editor of the Examples view shows one at a time, picked from the file list.", + summary: "The emitted Python, `net.py` first. The Python editor of the Playground view shows one at a time, picked from the file list.", call: "CompiledFile[] // { path, text, trace }", input: "`emit`", output: "The Python editor, and each file's trace to `traces`", diff --git a/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.css b/pocs/zeroth-playground/playground/playground-view/compiler-options-panel.css similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/compiler-options-panel.css rename to pocs/zeroth-playground/playground/playground-view/compiler-options-panel.css diff --git a/pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx b/pocs/zeroth-playground/playground/playground-view/compiler-options-panel.tsx similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/compiler-options-panel.tsx rename to pocs/zeroth-playground/playground/playground-view/compiler-options-panel.tsx diff --git a/pocs/zeroth-playground/playground/examples-view/diagnostics-list.css b/pocs/zeroth-playground/playground/playground-view/diagnostics-list.css similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/diagnostics-list.css rename to pocs/zeroth-playground/playground/playground-view/diagnostics-list.css diff --git a/pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx b/pocs/zeroth-playground/playground/playground-view/diagnostics-list.tsx similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/diagnostics-list.tsx rename to pocs/zeroth-playground/playground/playground-view/diagnostics-list.tsx diff --git a/pocs/zeroth-playground/playground/examples-view/file-list.css b/pocs/zeroth-playground/playground/playground-view/file-list.css similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/file-list.css rename to pocs/zeroth-playground/playground/playground-view/file-list.css diff --git a/pocs/zeroth-playground/playground/examples-view/file-list.tsx b/pocs/zeroth-playground/playground/playground-view/file-list.tsx similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/file-list.tsx rename to pocs/zeroth-playground/playground/playground-view/file-list.tsx diff --git a/pocs/zeroth-playground/playground/examples-view/hover.ts b/pocs/zeroth-playground/playground/playground-view/hover.ts similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/hover.ts rename to pocs/zeroth-playground/playground/playground-view/hover.ts diff --git a/pocs/zeroth-playground/playground/examples-view/module-edits.test.ts b/pocs/zeroth-playground/playground/playground-view/module-edits.test.ts similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/module-edits.test.ts rename to pocs/zeroth-playground/playground/playground-view/module-edits.test.ts diff --git a/pocs/zeroth-playground/playground/examples-view/module-edits.ts b/pocs/zeroth-playground/playground/playground-view/module-edits.ts similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/module-edits.ts rename to pocs/zeroth-playground/playground/playground-view/module-edits.ts diff --git a/pocs/zeroth-playground/playground/examples-view/module-view.css b/pocs/zeroth-playground/playground/playground-view/module-view.css similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/module-view.css rename to pocs/zeroth-playground/playground/playground-view/module-view.css diff --git a/pocs/zeroth-playground/playground/examples-view/module-view.tsx b/pocs/zeroth-playground/playground/playground-view/module-view.tsx similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/module-view.tsx rename to pocs/zeroth-playground/playground/playground-view/module-view.tsx diff --git a/pocs/zeroth-playground/playground/examples-view/examples-view.css b/pocs/zeroth-playground/playground/playground-view/playground-view.css similarity index 100% rename from pocs/zeroth-playground/playground/examples-view/examples-view.css rename to pocs/zeroth-playground/playground/playground-view/playground-view.css diff --git a/pocs/zeroth-playground/playground/examples-view/examples-view.tsx b/pocs/zeroth-playground/playground/playground-view/playground-view.tsx similarity index 96% rename from pocs/zeroth-playground/playground/examples-view/examples-view.tsx rename to pocs/zeroth-playground/playground/playground-view/playground-view.tsx index 8989def9..dae8818c 100644 --- a/pocs/zeroth-playground/playground/examples-view/examples-view.tsx +++ b/pocs/zeroth-playground/playground/playground-view/playground-view.tsx @@ -29,9 +29,9 @@ import type { Document } from "../app/document"; import type { LineDecoration } from "../editor/line-decorations"; import "../ui/resizable.css"; -import "./examples-view.css"; +import "./playground-view.css"; -type ExamplesViewProps = { +type PlaygroundViewProps = { document: Document; /** The document compiled, once per render, by the app. */ compilation: Compilation; @@ -63,12 +63,12 @@ function diagnosticLines( } /** - * The Examples view: the example's page down the left; the IR editor over + * The Playground view: the example's page down the left; the IR editor over * the compiler options, and the emitted module beside them; the net preview * under both. A hover in the IR, the module or the preview lights what * belongs with it in the other two. */ -export const ExamplesView: React.FC = ({ document, compilation, onChange, hover, onHover }) => { +export const PlaygroundView: React.FC = ({ document, compilation, onChange, hover, onHover }) => { const irEditorRef = useRef(null); const gridRef = useGridRef(); const optionsRef = usePanelRef(); diff --git a/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx b/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx index 3e8f685e..ae416c3c 100644 --- a/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx +++ b/pocs/zeroth-playground/playground/semantics-view/semantics-view.tsx @@ -23,7 +23,7 @@ type SemanticsViewProps = { * The questions on the semantics of the compilation: the list by topic on * the left, and on the right the selected question's card, or the intro and * the topics when none is selected. The view reads the register and the - * catalog alone, never the document the Examples view has open. + * catalog alone, never the document the Playground view has open. */ export const SemanticsView: React.FC = ({ selected, onSelect }) => { const listRef = usePanelRef(); diff --git a/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx b/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx index 5ca7e71e..9c914565 100644 --- a/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx +++ b/pocs/zeroth-playground/playground/semantics-view/showing-view.tsx @@ -10,7 +10,7 @@ import "./showing-view.css"; /** * One place a question shows: the example and the options that bring the - * behaviour out, a way to open them in the Examples view, and the lines the + * behaviour out, a way to open them in the Playground view, and the lines the * compiler writes for the net item, compiled here from the catalog. */ export const ShowingView: React.FC<{ showing: Showing }> = ({ showing }) => { @@ -33,7 +33,7 @@ export const ShowingView: React.FC<{ showing: Showing }> = ({ showing }) => {
    - + {/* The picker names the example the Playground and Compiler views work on; the Semantics view is general. */} {evidence.kind === "refused" ? (

    {evidence.message}

    diff --git a/pocs/zeroth-playground/playground/ui/back-link.css b/pocs/zeroth-playground/playground/ui/back-link.css index ae6bb2f5..3b4ef9fb 100644 --- a/pocs/zeroth-playground/playground/ui/back-link.css +++ b/pocs/zeroth-playground/playground/ui/back-link.css @@ -12,6 +12,7 @@ border-radius: 0.4rem; color: var(--accent); line-height: 1; + text-decoration: none; white-space: nowrap; transition: color var(--fast) var(--ease), diff --git a/pocs/zeroth-playground/playground/ui/back-link.tsx b/pocs/zeroth-playground/playground/ui/back-link.tsx index 7995213e..153ebb34 100644 --- a/pocs/zeroth-playground/playground/ui/back-link.tsx +++ b/pocs/zeroth-playground/playground/ui/back-link.tsx @@ -3,16 +3,27 @@ import "./back-link.css"; type BackLinkProps = { /** An id another element hands the keyboard focus to. */ id?: string; - onClick: () => void; + /** Where the link goes; without one it is a button that only acts. */ + href?: string; + onClick?: () => void; children: React.ReactNode; }; /** The way back from a card to the list it came from: a chevron and a label, like a breadcrumb. */ -export const BackLink: React.FC = ({ id, onClick, children }) => { - return ( - + ) : ( + + {inner} + ); }; From 0e0d4f93732054c75663dbdd5f6f20eaf947e1cf Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 2 Oct 2026 19:15:55 +0200 Subject: [PATCH 13/20] Keep the views inside the window whatever a line of code measures The app's grid had no column definition, so its one column grew to the longest unbreakable line of the view shown, the quoted Python of a Semantics question, and the panels spilled past the window when the tab changed. The column is now minmax(0, 1fr), and the workspace may shrink, so a long line scrolls inside its block instead. --- pocs/zeroth-playground/playground/app/app.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pocs/zeroth-playground/playground/app/app.css b/pocs/zeroth-playground/playground/app/app.css index 2732d6f5..448a71e2 100644 --- a/pocs/zeroth-playground/playground/app/app.css +++ b/pocs/zeroth-playground/playground/app/app.css @@ -1,9 +1,12 @@ +/* One column that never grows past the window: a long line in a view scrolls inside it. */ .app { display: grid; grid-template-rows: var(--header-height) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); height: 100%; } .workspace { + min-width: 0; min-height: 0; } From 573ed64c93acc88a5ca34909516ebdbbb38a28a8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 2 Oct 2026 20:06:01 +0200 Subject: [PATCH 14/20] Fade a question title the row cuts, and keep the owner on the right A title stays on one line. One the row cuts fades into the background over its last 2rem, through a mask an animation on the title's own scroll timeline applies: the timeline is active only while the title overflows, so a title that fits stays whole. A browser without scroll-driven animations clips the title instead. --- .../semantics-view/question-list.css | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pocs/zeroth-playground/playground/semantics-view/question-list.css b/pocs/zeroth-playground/playground/semantics-view/question-list.css index 3fab8ade..36bce45b 100644 --- a/pocs/zeroth-playground/playground/semantics-view/question-list.css +++ b/pocs/zeroth-playground/playground/semantics-view/question-list.css @@ -71,8 +71,25 @@ } } +/* + * A title stays on one line, and one the row cuts fades out at the cut, so + * the owner keeps its place on the right. The fade is a mask that an + * animation on the title's own scroll timeline applies: the timeline is + * active only while the title overflows, so a title that fits stays whole. + */ .question-list__title { - overflow-wrap: anywhere; + min-width: 0; + overflow: hidden; + white-space: nowrap; + animation: question-title-fade linear both; + animation-timeline: scroll(self inline); +} + +@keyframes question-title-fade { + from, + to { + mask-image: linear-gradient(to right, black calc(100% - 2rem), transparent); + } } .question-list__owner { From 12d0aa671aa43134a94d7f539eaf55a84eaafeaa Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Oct 2026 04:33:38 +0200 Subject: [PATCH 15/20] Open every example page on its problem, and group the examples as steps and clocks Each page now says the problem its net poses in one or two sentences, quotes the lines the compiler writes for it, and lists at most two options. The pages shrink by a third to a half; all but the two with a figure fit one screen. The rungs follow the compiler's divide: Steps holds the nets compiled in steps, plain or with coins, since a coin is tested once per step; Clocks holds the three nets compiled in continuous time. The arcs example is named by what it asks, "What an arc tests". The ladder keeps its eight listed examples, each still carrying one idea. --- pocs/zeroth-playground/README.md | 2 +- pocs/zeroth-playground/examples/README.md | 28 +++++++++---------- pocs/zeroth-playground/examples/arcs/meta.ts | 2 +- pocs/zeroth-playground/examples/arcs/page.mdx | 10 ++----- .../examples/birth-death-clocked/meta.ts | 2 +- .../examples/birth-death-clocked/page.mdx | 24 ++++------------ .../examples/birth-death/meta.ts | 2 +- .../examples/birth-death/page.mdx | 19 ++++--------- .../examples/bucket/page.mdx | 10 ++----- .../examples/cafe-queue/meta.ts | 2 +- .../examples/cafe-queue/page.mdx | 20 ++----------- .../examples/capacity/page.mdx | 12 +++----- pocs/zeroth-playground/examples/catalog.ts | 4 +-- .../examples/conflict-clocked/meta.ts | 2 +- .../examples/conflict-clocked/page.mdx | 17 +++-------- .../examples/conflict/page.mdx | 22 ++++----------- pocs/zeroth-playground/examples/cycle/meta.ts | 2 +- .../zeroth-playground/examples/cycle/page.mdx | 21 +++++--------- 18 files changed, 62 insertions(+), 139 deletions(-) diff --git a/pocs/zeroth-playground/README.md b/pocs/zeroth-playground/README.md index 5a249e84..f3ff0cdf 100644 --- a/pocs/zeroth-playground/README.md +++ b/pocs/zeroth-playground/README.md @@ -22,7 +22,7 @@ pnpm dev - **Views.** The IR editor has highlighting, completion of the IR's keys and places, and a hover that reads the IR trace. The module view lists the Python files and the diagnostics, each at the IR line of its item. The preview draws the net as an SVG laid out with elkjs. - **Options beside the IR.** The Compiler options panel under the IR editor holds the compiler options; the IR carries none. An option that does not apply to the net is greyed, with the reason. Each example opens with its own options, and Reset restores them with the text. - **Cross-highlighting.** Hovering a line in either editor lights the lines on the other side that name the same net item, and the item in the preview. Hovering a place or a transition in the preview lights its lines on both sides. -- **The example ladder.** The examples climb from plain nets (Steps) to stochastic ones (Rates), each adding one idea and named by it. The picker in the header lists them. Each has a page; most end with open questions, each naming who can settle it: HASH, Zeroth or both. Two pages carry a CSS animation, and *Rates as clocks* has an explorer with a Δt slider, a draw and the coin's rate. +- **The example ladder.** The examples climb from nets compiled in steps, plain or with coins, to nets compiled in continuous time under clocks, each adding one idea and named by it. The picker in the header lists them. Each has a page; most end with open questions, each naming who can settle it: HASH, Zeroth or both. Two pages carry a CSS animation, and *Rates as clocks* has an explorer with a Δt slider, a draw and the coin's rate. - **The Compiler view.** A switch in the header opens a guide, a graph of the pipeline's stages and a card per stage with a live sample from the open example. `#compiler`, or `#compiler/capacity`, opens it. - **The Semantics view.** The questions still open between HASH and Zeroth on what a net means as modules, grouped by topic. Each one quotes the Python that shows it today and opens the example that shows it. `#semantics`, or `#semantics/ties-under-clocks`, opens it. diff --git a/pocs/zeroth-playground/examples/README.md b/pocs/zeroth-playground/examples/README.md index 2acfa00c..e4c89653 100644 --- a/pocs/zeroth-playground/examples/README.md +++ b/pocs/zeroth-playground/examples/README.md @@ -4,24 +4,24 @@ One folder per Petri net: its IR document, the compiler options it opens with, a ## The ladder -The examples climb a ladder of rungs, from the plainest net up. Each one adds one idea to the ones before it, and is named by that idea, then by the net it uses: "Rates as coins", using Birth–death. `LADDER` in `catalog.ts` lists the rungs: +The examples climb a ladder of rungs. Each one adds one idea to the ones before it, and is named by that idea, then by the net it uses: "Rates as coins", using Birth–death. A page opens on the problem its net poses, quotes the lines the compiler writes for it, and stops. `LADDER` in `catalog.ts` lists the rungs: | Rung | Holds | | --- | --- | -| `steps` | plain nets: how one step of the net becomes modules | -| `rates` | stochastic nets: coins tested each step, or clocks in continuous time | +| `steps` | nets compiled in steps, plain or with coins: what one step of the net means as modules | +| `clocks` | stochastic nets compiled in continuous time, in Zeroth's SPN theory | | `colours` | coloured tokens; refused in the playground, which has no code parser, and unlisted | | Order | Example | Net | Opens with | What you learn | | --- | --- | --- | --- | --- | -| 10 | One step as a module | Cycle | monolithic | A step fires every enabled transition once, in record order: it takes at once and lands at the end. The step is one `next` over an Int per place, or one module per transition and per place, read through `X(...)`. | -| 20 | Who gets the token | Conflict | modular, `conflicts: nondet` | Record order settles a shared input place through `avail_Pool`. Nondet adds a `pick_T` per contender, so every resolution is a run. A transition in no conflict gets no pick. | -| 30 | A capped place | Capacity | modular | `fill_Buffer` lets a later producer count the token an earlier one adds in the same step. Clocks refuse capacities. | -| 40 | Weighted, read and inhibitor arcs | Arcs | modular | A weight w tests `P >= w` and takes w, a read arc tests without taking, an inhibitor arc tests `P < w`. | -| 50 | Rates as coins | Birth–death | modular, `dt: 0.5` | A rate becomes an input draw `u_T` tested against e^(−λ·Δt), computed at compile time. Places become Real in LRA, or stay Int with a `Draw_T` module per transition. | -| 60 | Rates as clocks | Birth–death | `rates: clock` | A rate becomes a clock `clk_T` armed with `exp(rate)` and an event `ev_T`, places become Nat counters, and `compose` hides the clocks. A coin's rate stays below λ. | -| 70 | Two inputs under clocks | Café queue | `rates: clock` | A transition with two input places keeps both tests and pauses its clock while either is empty, where Zeroth's model lets the places decide. | -| 80 | Conflicts under clocks | Conflict with rates | `rates: clock`, `conflicts: nondet` | A conflict becomes a race between clocks. A pick is read at expiry, and a false pick stops time. | +| 10 | One step as a module | Cycle | monolithic | A step takes at once and lands at the end, as one `next` over an Int per place, or as one module per transition and place. | +| 20 | Who gets the token | Conflict | modular, `conflicts: nondet` | Record order settles a shared place; a pick per contender leaves it open, so every resolution is a run. | +| 30 | A capped place | Capacity | modular | `fill_Buffer` counts a token that lands at the end of the step; room freed later in the step does not count. | +| 40 | What an arc tests | Arcs | modular | A weight takes w tokens, a read arc tests without taking, an inhibitor arc tests emptiness. | +| 50 | Rates as coins | Birth–death | modular, `dt: 0.5` | A rate becomes a draw tested against e^(−λ·Δt) each step; the places become Real in LRA. | +| 60 | Rates as clocks | Birth–death | `rates: clock` | A rate becomes a clock and an event in SPN; a coin's rate stays below λ. | +| 70 | Two inputs under clocks | Café queue | `rates: clock` | The transition pauses its clock while an input place is empty, where Zeroth's model lets the places decide. | +| 80 | Conflicts under clocks | Conflict with rates | `rates: clock`, `conflicts: nondet` | A conflict is a race between clocks; a false pick stops time, and a tie produces twice. | | 90 | Code strings are refused, unlisted | Bucket | monolithic | A rate written as code is refused with `code-not-parsed` until a code parser is passed. | The picker lists the listed examples rung by rung, sorted by `order`, and skips a rung with none. Put a new example where its idea builds on the ones before it, and only when no example already teaches that idea. @@ -46,7 +46,7 @@ The folder name is the id: the picker's key, the page's key and the URL hash, `# | `feature` | what the example tackles: its name in the picker and its page heading | | `title` | the net's name, shown above the heading | | `summary` | one line on what it shows, in the picker | -| `rung` | `steps`, `rates` or `colours` | +| `rung` | `steps`, `clocks` or `colours` | | `order` | its place up the ladder; examples sort by it, in steps of 10 | | `listed` | `false` keeps it out of the picker; its hash still opens it | | `options` | the compiler options it opens with | @@ -98,9 +98,9 @@ return X(u_Birth) >= 0.36787944117144233 - The documentation view writes the net's name and the heading from `meta.ts`; the page starts with its first paragraph. - The documentation view passes `Figure` and `Question` to every page as MDX components, from `playground/docs/page-components.ts`. A page uses them without importing them, and imports only its own `./components/`. It also renders fenced code through `CodeBlock`, which wraps a long line under a hanging indent, so quote a line of Python in a fenced block when it is longer than about 25 characters: inline code does not wrap. -- Open with the problem, not with the net or the options. Name the IR items and the Python names the reader will see. Say once, on the page that needs it, what a later page builds on. Name another example by its feature, in italics: *Rates as clocks*. +- Open with the problem, not with the net or the options, so a reader gets it at a glance. A page is about a hundred words: the problem in one or two sentences, one quoted answer, and at most two options. Name the IR items and the Python names the reader will see. Name another example by its feature, in italics: *Rates as clocks*. - **Options** lists only the options that change this net's output, never one the panel greys out, and not the shape the example opens with. Leave the section out when the body already covers them. -- Only the first page, Cycle, has a **Hover** section: one bullet on what pointing at a line or a node does. +- Only the first page, Cycle, ends with one line on what pointing at a line or a node does. - An open question lives in [semantics/](../semantics/README.md), one folder per question, and a page only references it: `` renders a card with its owner, its status and a way into the Semantics view. An id the register does not hold fails the page render test. Each question sits on the one page where it matters most. - A refused example says why under `### Refused here`, and what the compiler would write with a code parser. - Maths is written as `$...$` or `$$...$$` and rendered to SVG at build time. Keep it short. diff --git a/pocs/zeroth-playground/examples/arcs/meta.ts b/pocs/zeroth-playground/examples/arcs/meta.ts index 18f52ee3..d9592ff7 100644 --- a/pocs/zeroth-playground/examples/arcs/meta.ts +++ b/pocs/zeroth-playground/examples/arcs/meta.ts @@ -1,7 +1,7 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Weighted, read and inhibitor arcs", + feature: "What an arc tests", title: "Arcs", summary: "An arc that takes two tokens, one that reads a place, one that needs it empty.", rung: "steps", diff --git a/pocs/zeroth-playground/examples/arcs/page.mdx b/pocs/zeroth-playground/examples/arcs/page.mdx index 6d968203..09cad0af 100644 --- a/pocs/zeroth-playground/examples/arcs/page.mdx +++ b/pocs/zeroth-playground/examples/arcs/page.mdx @@ -1,18 +1,14 @@ -An arc can do more than take one token. `Join` takes two `Parts` through an arc of weight 2, reads `Power` without taking from it, and fires only while `Jam` is empty. - -`Transition_Join` tests the three arcs in one guard: +`Join` takes two `Parts`, reads `Power` without taking from it, and fires only while `Jam` is empty. Each kind of arc tests something else: ```python return (Parts >= 2) & (Power >= 1) & (Jam < 1) ``` -- An arc of weight w tests `P >= w` and takes w tokens: `Place_Parts` applies `Parts - 2`. +- A weight w tests `P >= w` and takes w tokens: `Place_Parts` applies `Parts - 2`. - A read arc tests `P >= w` and takes nothing. - An inhibitor arc tests `P < w`. -The read and inhibitor arcs move no token, so `Place_Power` and `Place_Jam` return their count unchanged. Add `Jam: 1` to the marking, and `Join` never fires. - -Clock rates test a read arc as `P != 0` and an inhibitor arc as `P == 0`. They refuse an arc of weight above 1 with `clocks-arc-weight`, because an SPN event moves one token per arc. +Add `Jam: 1` to the marking, and `Join` never fires. Under clocks a read arc tests `P != 0` and an inhibitor arc `P == 0`, while a weight above 1 is refused: an SPN event moves one token per arc. ### Open questions diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts b/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts index c9f697b0..825d0b33 100644 --- a/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts +++ b/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts @@ -4,7 +4,7 @@ export default { feature: "Rates as clocks", title: "Birth–death", summary: "A rate becomes a clock that runs down in continuous time.", - rung: "rates", + rung: "clocks", order: 60, listed: true, options: { rates: "clock" }, diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx b/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx index 1fb651b2..ed4f0596 100644 --- a/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx +++ b/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx @@ -1,40 +1,26 @@ import { CoinClockExplorer } from "./components/coin-clock-explorer"; -Under clocks, each transition owns a clock armed with an exponential delay at its rate. Time flows until a clock reaches 0, then that transition fires and its clock re-arms. +Under clocks, each transition owns a clock armed with an exponential delay at its rate. Time flows until a clock reaches 0; the transition fires and the clock re-arms. -The compiler writes this in Zeroth's SPN theory. Each transition gets a clock `clk_T` and an event `ev_T` that toggles when it fires. `Transition_Birth` reads: +The compiler writes this in Zeroth's SPN theory: a clock `clk_T` and an event `ev_T` per transition, a Nat per place. `Transition_Birth` fires at 0 and re-arms: ```python fires_Birth = clk_Birth == 0 return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) ``` -An `ite` with `None` as its last branch has no value where its condition fails, so the event toggles only when `Birth` fires. The `flow` method runs the clock down against the time `t` while it is not negative, and holds the event still with `0`: +Its `flow` runs the clock down against the time `t`. Past 0 it has no value, which stops time, and the event's flow `0` keeps the event still: ```python return ite(clk_Birth >= 0, -1 * d(t), None), 0 ``` -Each place is a Nat. `Place_Population` adds or takes one token when `fired` sees the event of `Birth` or `Death`. `compose` hides the clocks: `hide={clk_Birth, clk_Death}`. +`Death` needs a token, so its clock runs only while `Population` holds one. `Place_Population` counts the events with `fired`, and `compose` hides the clocks. -`Death` also needs a token in `Population`, so its clock runs down only while the place holds one: - -```python -ite(Population != 0, -1 * d(t), 0 * d(t)) -``` - -*Two inputs under clocks* shows where that choice matters. - -A coin reads its draw $u$ as the delay $\tau = -\ln(u)/\lambda$ of the clock it stands for, and $u \ge \theta$ exactly when $\tau \le \Delta t$. But a clock can expire twice in one step, while a coin fires at most once, so the coin's rate $p/\Delta t$ stays below $\lambda$. - -
    +
    -### Options - -- **Rates** on coin compiles the net in steps, as *Rates as coins* shows. - ### Open questions diff --git a/pocs/zeroth-playground/examples/birth-death/meta.ts b/pocs/zeroth-playground/examples/birth-death/meta.ts index 2b82fc7a..81b55213 100644 --- a/pocs/zeroth-playground/examples/birth-death/meta.ts +++ b/pocs/zeroth-playground/examples/birth-death/meta.ts @@ -4,7 +4,7 @@ export default { feature: "Rates as coins", title: "Birth–death", summary: "A rate becomes a draw tested against a threshold each step.", - rung: "rates", + rung: "steps", order: 50, listed: true, options: { shape: "modular", dt: 0.5 }, diff --git a/pocs/zeroth-playground/examples/birth-death/page.mdx b/pocs/zeroth-playground/examples/birth-death/page.mdx index 60cdf86d..422c3410 100644 --- a/pocs/zeroth-playground/examples/birth-death/page.mdx +++ b/pocs/zeroth-playground/examples/birth-death/page.mdx @@ -1,27 +1,20 @@ import { CoinDraws } from "./components/coin-draws"; -A transition at rate $\lambda$ fires within a step of length $\Delta t$ with probability $1 - e^{-\lambda \Delta t}$. The compiler turns that into a coin: an input draw `u_T` and the test `X(u_T) >= θ`, with $\theta = e^{-\lambda \Delta t}$ computed at compile time. +A transition at rate $\lambda$ fires within a step of length $\Delta t$ with probability $1 - e^{-\lambda \Delta t}$. The compiler makes that a coin: a draw `u_T` each step, tested against a threshold fixed at compile time. -Over a step of 0.5, `Birth` at rate 2 gets $\theta = e^{-1} \approx 0.37$, so it fires in about 63% of steps: +`Birth` at rate 2, over a step of 0.5, fires when its draw reaches $e^{-1} \approx 0.37$, so in about 63% of steps: ```python return X(u_Birth) >= 0.36787944117144233 ``` -
    +
    -`Death` also needs a token in `Population`, so it tests the place beside its draw: - -```python -return (Population >= 1.0) & (X(u_Death) >= 0.6065306597126334) -``` - -The draws are Reals, so the places are Reals too and the modules use the theory LRA: `Population = Var(REAL)`, `Population + 1.0`. +`Death` also needs a token, so it tests `Population >= 1.0` beside its draw. The draws are Reals, so the places are Reals too, in the theory LRA. ### Options -- **Time step** sets $\Delta t$, and every threshold follows it. -- **Marking** on Int keeps the places Int and moves each draw into an LRA module, `Draw_Birth`, that writes a Bool `hit_Birth`. The guard reads `X(hit_Birth)` in place of the draw. -- **Rates** on clock compiles the net in continuous time, as *Rates as clocks* shows. +- **Time step** sets $\Delta t$, and every threshold follows. +- **Marking** on Int keeps the places Int and moves each draw into an LRA module, `Draw_Birth`, that writes the Bool the guard reads. diff --git a/pocs/zeroth-playground/examples/bucket/page.mdx b/pocs/zeroth-playground/examples/bucket/page.mdx index 9708c3c6..fc8f8c50 100644 --- a/pocs/zeroth-playground/examples/bucket/page.mdx +++ b/pocs/zeroth-playground/examples/bucket/page.mdx @@ -1,6 +1,4 @@ -`Take` fires at a rate that reads a token's attribute, so the IR writes that rate as code. The compiler reads code only through a code parser it is given. - -`Pool` holds two `Ball` tokens with a real `x`, and the rate of `Take` reads the first token: +`Take` fires at a rate that reads a token's attribute, so the IR writes the rate as code. The compiler reads code only through a parser it is given, and this playground passes none. ```ts return 0.6 * (input.Pool[0].x / 100); @@ -8,11 +6,7 @@ return 0.6 * (input.Pool[0].x / 100); ### Refused here -This playground passes no code parser, so the net is refused with `code-not-parsed`. A TypeScript parser does not fit its single HTML file. - -With a parser, the monolithic step gives each position of `Pool` a present flag and an `x`: `Pool_0_present` and `Pool_0_x`. `Take` tries the tokens in order and fires on the first whose rate reaches an exponential draw, `e_Take`. At the end of the step the surviving tokens close up in position order. - -**Shape** on modular refuses the net with `modular-coloured-not-lowered`, before it reads any code. +The net is refused with `code-not-parsed`; a TypeScript parser does not fit one HTML file. With a parser, each position of `Pool` gets a present flag and an `x`, `Take` tries the tokens in order, and the survivors close up at the end of the step. **Shape** on modular refuses the net earlier, with `modular-coloured-not-lowered`. ### Open questions diff --git a/pocs/zeroth-playground/examples/cafe-queue/meta.ts b/pocs/zeroth-playground/examples/cafe-queue/meta.ts index e735305b..d1d753c4 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/meta.ts +++ b/pocs/zeroth-playground/examples/cafe-queue/meta.ts @@ -4,7 +4,7 @@ export default { feature: "Two inputs under clocks", title: "Café queue", summary: "A clock that runs only while both input places hold a token.", - rung: "rates", + rung: "clocks", order: 70, listed: true, options: { rates: "clock" }, diff --git a/pocs/zeroth-playground/examples/cafe-queue/page.mdx b/pocs/zeroth-playground/examples/cafe-queue/page.mdx index 5a7503a2..cc94d1a1 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/page.mdx +++ b/pocs/zeroth-playground/examples/cafe-queue/page.mdx @@ -1,26 +1,12 @@ -`BeginService` needs a customer in `Waiting` and a free member of staff in `FreeStaff`. Under clocks the compiler must decide what an empty input place does to the clock. +`BeginService` needs a customer in `Waiting` and a free member of staff in `FreeStaff`. Under clocks, what does an empty input place do to its clock? -The transition decides. `fires_BeginService` keeps both tests: +Here the transition decides: its clock runs only while both places hold a token, and it fires only then. ```python fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) ``` -The clock runs down only while both places hold a token, and pauses otherwise: - -```python -return ite(clk_BeginService >= 0, ite((Waiting != 0) & (FreeStaff != 0), -1 * d(t), 0 * d(t)), None), 0 -``` - -Zeroth's own `birth_death.py` lets the places decide instead. The clock always runs, and at expiry each input place gives a token if it has one. With one input place, as for `Death` on *Rates as clocks*, the two rules agree, since an exponential delay has no memory. With two they do not: `BeginService` could take a customer from `Waiting` while `FreeStaff` is empty. - -### Options - -- **Rates** on coin tests both places beside the draw, in one guard: - - ```python - return (Waiting >= 1.0) & (FreeStaff >= 1.0) & (X(u_BeginService) >= 0.6703200460356393) - ``` +Zeroth's own `birth_death.py` lets the places decide: the clock always runs, and at expiry each input place gives a token if it has one. With one input the two rules agree, since an exponential delay has no memory. With two they do not: `BeginService` could take a customer while `FreeStaff` is empty. ### Open questions diff --git a/pocs/zeroth-playground/examples/capacity/page.mdx b/pocs/zeroth-playground/examples/capacity/page.mdx index cab1ad07..7a4fc649 100644 --- a/pocs/zeroth-playground/examples/capacity/page.mdx +++ b/pocs/zeroth-playground/examples/capacity/page.mdx @@ -1,6 +1,6 @@ -`Buffer` holds at most two tokens, and `PutLeft` and `PutRight` can both fill it in one step. So the second producer must count the token the first one adds, although that token lands only at the end of the step. +`Buffer` holds two tokens at most, and `PutLeft` and `PutRight` can both fill it in one step. The second producer must count a token that lands only at the end of the step. -`PutLeft` comes first, so it tests the marking: `Buffer + 1 <= 2`. `PutRight` tests `fill_Buffer + 1 <= 2`, where the local `fill_Buffer` is what `Buffer` would hold if the step ended now: +`PutRight` tests `fill_Buffer`, what `Buffer` would hold if the step ended now: ```python fill_Buffer = Buffer @@ -8,15 +8,11 @@ fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) return (RightSource >= 1) & (fill_Buffer + 1 <= 2) ``` -These are the lines of `Transition_PutRight`. It rebuilds the fill from `X(fire_PutLeft)` as a taker rebuilds `avail_Pool` on *Who gets the token*, while `Place_Buffer` alone writes `Buffer`. - -`Take` comes after both producers in record order, so the room it frees counts only from the next step. A full `Buffer` that `Take` drains in this step still blocks both producers. - -Clock rates refuse a capacity with `clocks-capacity`, so a stochastic net with a capped place compiles under coins only. +`Take` comes after both producers, so the room it frees counts from the next step: a full `Buffer` blocks both, even in a step where `Take` drains it. Clocks refuse a capacity, since SPN tests a count against zero only. ### Options -- **Shape** on monolithic gives one `Capacity` class: `fill_Buffer = Buffer` before the sweep, every firing that moves tokens in `Buffer` updates it, and the class returns it as the new `Buffer`. +- **Shape** on monolithic keeps one `fill_Buffer` local, updated by every firing that moves `Buffer`. ### Open questions diff --git a/pocs/zeroth-playground/examples/catalog.ts b/pocs/zeroth-playground/examples/catalog.ts index 2ebdc0d5..b81fd7fe 100644 --- a/pocs/zeroth-playground/examples/catalog.ts +++ b/pocs/zeroth-playground/examples/catalog.ts @@ -2,7 +2,7 @@ import type { CompilerOptions } from "../compiler"; /** A rung of the ladder: the examples it holds tackle features of one kind. */ export type Rung = { - id: "steps" | "rates" | "colours"; + id: "steps" | "clocks" | "colours"; /** The rung's heading in the picker. */ title: string; }; @@ -10,7 +10,7 @@ export type Rung = { /** The ladder, from the plainest net up; examples/README.md says what each rung holds. */ export const LADDER: readonly Rung[] = [ { id: "steps", title: "Steps" }, - { id: "rates", title: "Rates" }, + { id: "clocks", title: "Clocks" }, { id: "colours", title: "Coloured tokens" }, ]; diff --git a/pocs/zeroth-playground/examples/conflict-clocked/meta.ts b/pocs/zeroth-playground/examples/conflict-clocked/meta.ts index 9549e635..becb5950 100644 --- a/pocs/zeroth-playground/examples/conflict-clocked/meta.ts +++ b/pocs/zeroth-playground/examples/conflict-clocked/meta.ts @@ -4,7 +4,7 @@ export default { feature: "Conflicts under clocks", title: "Conflict with rates", summary: "A conflict as a race between clocks, with picks read at expiry.", - rung: "rates", + rung: "clocks", order: 80, listed: true, options: { rates: "clock", conflicts: "nondet" }, diff --git a/pocs/zeroth-playground/examples/conflict-clocked/page.mdx b/pocs/zeroth-playground/examples/conflict-clocked/page.mdx index 798fcaf6..16fe26f4 100644 --- a/pocs/zeroth-playground/examples/conflict-clocked/page.mdx +++ b/pocs/zeroth-playground/examples/conflict-clocked/page.mdx @@ -1,31 +1,22 @@ import { ClockRace } from "./components/clock-race"; -Under clocks a conflict is a race. `TakeLeft` and `TakeRight` both take from `Pool`, and whichever clock reaches 0 first takes a token. +Under clocks a conflict is a race: `TakeLeft` and `TakeRight` both take from `Pool`, and the clock that reaches 0 first takes the token. -
    +
    -With **Conflicts** on nondet, each taker also needs its pick when its clock expires: +With **Conflicts** on nondet, a taker also needs its pick at expiry. A false pick stops time until it turns true. ```python fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft ``` -A pick is an input that nothing drives, read at expiry. `Return` is in no conflict and has none. A false pick stops time: the clock stays at 0, and no time passes until the pick turns true. - -`Place_Pool` applies one firing at a time. Each case of its `ite` needs the other events off: - -```python -fired_TakeLeft & ~fired_Return & ~fired_TakeRight & (Pool != 0) -``` - -So when both takers expire together, `Left` and `Right` each gain a token while `Pool` loses none. +`Place_Pool` applies one firing at a time, each case needing the other events off. So two takers that expire together both produce, while `Pool` loses no token. ### Options - **Conflicts** on sweep drops the picks: the first clock to expire fires. -- **Rates** on coin compiles the same net in steps of **Time step**, as on *Who gets the token*, with a coin per transition. ### Open questions diff --git a/pocs/zeroth-playground/examples/conflict/page.mdx b/pocs/zeroth-playground/examples/conflict/page.mdx index 679e4b65..c9107c1f 100644 --- a/pocs/zeroth-playground/examples/conflict/page.mdx +++ b/pocs/zeroth-playground/examples/conflict/page.mdx @@ -1,30 +1,18 @@ -`TakeLeft` and `TakeRight` both take from `Pool`, so when `Pool` holds one token only one of them can fire. The compiler has to say which one gets it. +`TakeLeft` and `TakeRight` both take from `Pool`. When one token is left, which of them gets it? -The step settles it in record order. `TakeLeft` comes first and tests `Pool`. `TakeRight` tests what `TakeLeft` left, `avail_Pool`: - -```python -avail_Pool = Pool -avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 1, avail_Pool) -``` - -So once `Pool` is down to one token, `TakeLeft` takes it in every step and `TakeRight` never fires again. - -**Conflicts** on nondet leaves the choice open. Each transition in the conflict also waits for an input Bool that nothing drives, `pick_TakeLeft` or `pick_TakeRight`: +In record order, `TakeLeft` tests `Pool` and `TakeRight` tests what is left, `avail_Pool`, so `TakeLeft` wins every step. **Conflicts** on nondet leaves the choice open: each contender also waits for a pick, an input that nothing drives. ```python return (Pool >= 1) & X(pick_TakeLeft) return (avail_Pool >= 1) & X(pick_TakeRight) ``` -Every way of resolving the conflict is then a run of the modules, and all picks true is record order. So a property proved under nondet holds whichever transition gets the token. - -`Return` takes from `Left`, which no other transition takes from. It is in no conflict, so it gets no pick, and it fires whenever `Left` holds a token. +Every way of resolving the conflict is then a run, and all picks true is record order. `Return` is in no conflict and gets no pick. ### Options -- **Conflicts** on sweep drops the picks, and record order alone decides. -- **Control** on open gives `TakeLeft`, which the IR marks `controllable`, an input `go_TakeLeft` that nothing drives, of the same kind as a pick. -- **Shape** on monolithic needs no `avail_Pool`: one `next` takes from `Pool` in record order. +- **Conflicts** on sweep drops the picks: record order alone decides. +- **Control** on open gives `TakeLeft`, marked `controllable`, an input `go_TakeLeft` of the same kind as a pick. ### Open questions diff --git a/pocs/zeroth-playground/examples/cycle/meta.ts b/pocs/zeroth-playground/examples/cycle/meta.ts index 71bb80eb..00e29a91 100644 --- a/pocs/zeroth-playground/examples/cycle/meta.ts +++ b/pocs/zeroth-playground/examples/cycle/meta.ts @@ -3,7 +3,7 @@ import type { ExampleMeta } from "../catalog"; export default { feature: "One step as a module", title: "Cycle", - summary: "One step of a plain net as one class, or as a module per transition and place.", + summary: "A step of a plain net, as one class or as a module per transition and place.", rung: "steps", order: 10, listed: true, diff --git a/pocs/zeroth-playground/examples/cycle/page.mdx b/pocs/zeroth-playground/examples/cycle/page.mdx index f71b3878..76f15ac2 100644 --- a/pocs/zeroth-playground/examples/cycle/page.mdx +++ b/pocs/zeroth-playground/examples/cycle/page.mdx @@ -1,12 +1,12 @@ import { OneStep } from "./components/one-step"; -One step of a plain net fires every enabled transition once, in record order. A firing takes its input tokens at once, and its output tokens land at the end of the step, so no token is produced and taken in the same step. +One step of a plain net fires every enabled transition once, in record order. A firing takes its tokens at once; the tokens it produces land at the end of the step. -
    +
    -The compiler writes the step as one class, `Cycle`, with an Int per place. Its `next` method is one step of the net: +The compiler writes that step as the `next` method of one class, with an Int per place: ```python fire_Go = A >= 1 @@ -17,17 +17,10 @@ A = ite(fire_Back, A + 1, A) B = ite(fire_Go, B + 1, B) ``` -The first four lines test and take in record order. The last two land the produced tokens. Every later example compiles this step. +Four lines test and take in order; the last two land the tokens. -**Shape** on modular splits the same step into one module per transition and one per place, joined by `compose`. `Transition_Go` writes the Bool `fire_Go` as `A >= 1`. `Place_A` applies every firing that moves `A`: +### Options -```python -A = ite(X(fire_Go), A - 1, A) -A = ite(X(fire_Back), A + 1, A) -``` - -`X(fire_Go)` reads the value `fire_Go` takes in this step, so `Place_A` waits for `Transition_Go`. In Zeroth a variable has one writer, so each place gets a module of its own. The plain and coin examples after this one open in this shape. - -### Hover +- **Shape** on modular splits the step into one module per transition and one per place. `Place_A` applies `ite(X(fire_Go), A - 1, A)`, where `X(fire_Go)` reads what `Transition_Go` wrote this step. A variable has one writer in Zeroth, so each place gets its own module. -- Point at a line in either editor, or at a node in the preview, to light the lines and nodes that match it. `Go:` lights the line of the step that sets `fire_Go`. +Point at a line in either editor, or at a node in the preview, to light what matches it. From 22dd624e356cadedee3515676e60268f4958073e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 9 Oct 2026 18:07:58 +0200 Subject: [PATCH 16/20] style(semantics): widen the gap before Shows in on a question card The page and the list of places a question shows ran together; the section now starts 3rem under the page, a clear break before the quoted Python. --- .../playground/semantics-view/question-card.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pocs/zeroth-playground/playground/semantics-view/question-card.css b/pocs/zeroth-playground/playground/semantics-view/question-card.css index 4d4bcb43..4cba4dee 100644 --- a/pocs/zeroth-playground/playground/semantics-view/question-card.css +++ b/pocs/zeroth-playground/playground/semantics-view/question-card.css @@ -35,9 +35,10 @@ margin: 0 0 0.6rem; } +/* A clear break after the page, before the places the question shows. */ .question-card__shows { max-width: 72rem; - margin-top: var(--s-5); + margin-top: calc(2 * var(--s-6)); } .question-card__shows-title { From e7a748f3f0ea4cb03954d5228eaababd99c175ab Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 12 Oct 2026 16:21:20 +0200 Subject: [PATCH 17/20] Delete readme.test.ts --- .../zeroth-playground/compiler/readme.test.ts | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 pocs/zeroth-playground/compiler/readme.test.ts diff --git a/pocs/zeroth-playground/compiler/readme.test.ts b/pocs/zeroth-playground/compiler/readme.test.ts deleted file mode 100644 index 23ac1a02..00000000 --- a/pocs/zeroth-playground/compiler/readme.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import README from "./README.md?raw"; -import { DIAGNOSTIC_CODES } from "./diagnostics"; -import { OPTION_NAMES, OPTIONS } from "./options"; - -/** The body rows of every table in the README whose header row is `header`, as trimmed cells. */ -function tableRows(header: string): string[][] { - return README.split("\n\n") - .map((block) => block.trim().split("\n")) - .filter(([first]) => first === header) - .flatMap((lines) => - lines.slice(2).map((line) => - line - .slice(1, -1) - .split("|") - .map((cell) => cell.trim()), - ), - ); -} - -function codeSpan(text: string | number): string { - return `\`${text}\``; -} - -describe("the README", () => { - it("lists every option in the order of OPTIONS, with its values, what it applies to and what it decides", () => { - // GIVEN the options table in the README - const rows = tableRows("| Option | Values, default first | Applies to | Decides |"); - // THEN each row says what the option's entry in OPTIONS says - expect(rows).toEqual( - OPTION_NAMES.map((name) => { - const { values, appliesTo, summary } = OPTIONS[name]; - const choices = - values === null - ? `${codeSpan(OPTIONS[name].default)}, or any positive number` - : values.map(codeSpan).join(", "); - return [codeSpan(name), choices, appliesTo, summary]; - }), - ); - }); - - it("lists every diagnostic code in the order of DIAGNOSTIC_CODES, with its meaning word for word", () => { - // GIVEN the diagnostics tables in the README - const rows = tableRows("| Code | Meaning |"); - // THEN together they hold one row per code, with the meaning DIAGNOSTIC_CODES gives it - expect(rows).toEqual( - Object.entries(DIAGNOSTIC_CODES).map(([code, meaning]) => [codeSpan(code), meaning]), - ); - }); -}); From 3207d5502e528d94e48ba06ecb826d7ea6e66b49 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 12 Oct 2026 17:57:00 +0200 Subject: [PATCH 18/20] Simplify the Zeroth playground for the meeting Keep five small examples and three questions for Zeroth. Remove the compiler walkthrough, secondary examples, animations, and Python editing. Use short explanations and LaTeX, with applicable options closed initially. Embed small nets in question bodies using the existing graph renderer. Give each SVG its own markers so multiple nets can render together. Update the catalogs, snapshots, tests, and documentation for the smaller presentation. Remove tests for deleted features and check embedded graphs. Validation: pnpm lint:tsc, pnpm test (213 passing), and pnpm build. Screenshot verification is blocked by Chromium's macOS startup failure. --- pocs/zeroth-playground/AGENTS.md | 2 +- pocs/zeroth-playground/README.md | 36 +- pocs/zeroth-playground/compiler/README.md | 2 +- pocs/zeroth-playground/compiler/mapping.md | 26 +- pocs/zeroth-playground/examples/README.md | 130 ++----- .../examples/__snapshots__/arcs/net.py | 73 ---- .../examples/__snapshots__/cafe-queue/net.py | 95 ++---- .../examples/__snapshots__/capacity/net.py | 111 ------ .../__snapshots__/conflict-clocked/net.py | 115 ------- .../examples/__snapshots__/conflict/net.py | 52 +-- pocs/zeroth-playground/examples/arcs/meta.ts | 11 - .../examples/arcs/net.pn.yaml | 24 -- pocs/zeroth-playground/examples/arcs/page.mdx | 19 -- .../components/coin-clock-explorer.css | 184 ---------- .../components/coin-clock-explorer.tsx | 62 ---- .../coin-clock-explorer/exponential.test.ts | 140 -------- .../coin-clock-explorer/exponential.ts | 42 --- .../components/coin-clock-explorer/format.ts | 4 - .../modifier-class.test.ts | 12 - .../coin-clock-explorer/modifier-class.ts | 4 - .../coin-clock-explorer/plot-frame.test.ts | 47 --- .../coin-clock-explorer/plot-frame.ts | 62 ---- .../coin-clock-explorer/range-field.tsx | 39 --- .../coin-clock-explorer/survival-plot.tsx | 155 --------- .../coin-clock-explorer/use-width.ts | 23 -- .../examples/birth-death-clocked/meta.ts | 3 - .../examples/birth-death-clocked/net.pn.yaml | 1 - .../examples/birth-death-clocked/page.mdx | 29 +- .../birth-death/components/coin-draws.css | 103 ------ .../birth-death/components/coin-draws.tsx | 73 ---- .../examples/birth-death/meta.ts | 3 - .../examples/birth-death/net.pn.yaml | 1 - .../examples/birth-death/page.mdx | 23 +- .../zeroth-playground/examples/bucket/meta.ts | 11 - .../examples/bucket/net.pn.yaml | 26 -- .../examples/bucket/page.mdx | 13 - .../examples/cafe-queue/meta.ts | 7 +- .../examples/cafe-queue/net.pn.yaml | 19 +- .../examples/cafe-queue/page.mdx | 10 +- .../examples/capacity/meta.ts | 11 - .../examples/capacity/net.pn.yaml | 30 -- .../examples/capacity/page.mdx | 21 -- .../examples/catalog.test.ts | 12 +- pocs/zeroth-playground/examples/catalog.ts | 47 +-- .../components/clock-race.css | 209 ------------ .../components/clock-race.tsx | 44 --- .../examples/conflict-clocked/meta.ts | 11 - .../examples/conflict-clocked/net.pn.yaml | 30 -- .../examples/conflict-clocked/page.mdx | 27 -- .../examples/conflict/meta.ts | 7 +- .../examples/conflict/net.pn.yaml | 8 +- .../examples/conflict/page.mdx | 16 +- .../examples/cycle/components/one-step.css | 105 ------ .../examples/cycle/components/one-step.tsx | 33 -- .../cycle/components/one-step/marking.tsx | 29 -- pocs/zeroth-playground/examples/cycle/meta.ts | 5 +- .../zeroth-playground/examples/cycle/page.mdx | 29 +- .../examples/examples.test.ts | 8 +- pocs/zeroth-playground/playground/README.md | 105 ++---- pocs/zeroth-playground/playground/app/app.tsx | 30 +- .../playground/app/document.test.ts | 28 +- .../playground/app/document.ts | 18 +- .../playground/app/example-picker.css | 174 +--------- .../playground/app/example-picker.tsx | 143 +------- .../playground/app/header.tsx | 8 +- .../playground/app/route.test.ts | 51 ++- .../zeroth-playground/playground/app/route.ts | 25 +- .../playground/app/view-switch.tsx | 6 +- .../compiler-view/compiler-guide.mdx | 56 --- .../compiler-view/compiler-view.tsx | 102 ------ .../playground/compiler-view/focus-handoff.ts | 22 -- .../compiler-view/pipeline-graph.css | 251 -------------- .../compiler-view/pipeline-graph.tsx | 90 ----- .../pipeline-graph/pipeline-edge.tsx | 47 --- .../pipeline-graph/pipeline-node.tsx | 76 ----- .../compiler-view/pipeline-layout.test.ts | 90 ----- .../compiler-view/pipeline-layout.ts | 169 ---------- .../playground/compiler-view/rich-text.tsx | 24 -- .../playground/compiler-view/stage-card.css | 96 ------ .../playground/compiler-view/stage-card.tsx | 86 ----- .../compiler-view/stage-card/sample-view.css | 88 ----- .../compiler-view/stage-card/sample-view.tsx | 35 -- .../playground/compiler-view/stage-focus.tsx | 59 ---- .../compiler-view/stage-overview.css | 61 ---- .../compiler-view/stage-overview.tsx | 58 ---- .../playground/compiler-view/stage-ref.css | 20 -- .../playground/compiler-view/stage-ref.tsx | 32 -- .../compiler-view/stage-sample.test.ts | 221 ------------ .../playground/compiler-view/stage-sample.ts | 75 ----- .../stage-sample/document-samples.ts | 125 ------- .../compiler-view/stage-sample/excerpt.ts | 47 --- .../stage-sample/graph-samples.ts | 106 ------ .../compiler-view/stage-sample/not-reached.ts | 19 -- .../compiler-view/stage-sample/rows.ts | 54 --- .../stage-sample/trace-samples.ts | 136 -------- .../playground/compiler-view/stages.ts | 318 ------------------ .../compiler-view/strategy-table.css | 36 -- .../compiler-view/strategy-table.tsx | 101 ------ .../playground/docs/example-net.css | 18 + .../playground/docs/example-net.tsx | 24 ++ .../playground/docs/example-pages.test.tsx | 11 - .../playground/docs/figures/figure.css | 19 -- .../playground/docs/figures/figure.tsx | 18 - .../playground/docs/page-components.ts | 4 +- .../playground/docs/question-ref.css | 53 +-- .../playground/docs/question-ref.tsx | 24 +- .../playground/editor/monaco-editor.tsx | 4 +- .../playground/options/option-edits.test.ts | 10 +- .../playground/options/option-labels.test.ts | 29 +- .../playground/options/option-labels.ts | 143 +------- .../compiler-options-panel.css | 78 +---- .../compiler-options-panel.tsx | 75 +---- .../playground-view/module-edits.test.ts | 37 -- .../playground-view/module-edits.ts | 37 -- .../playground-view/module-view.tsx | 28 +- .../playground-view/playground-view.css | 21 ++ .../playground-view/playground-view.tsx | 113 ++----- .../playground/pointer/pointer-motion.ts | 5 - .../playground/preview/net-graph.test.ts | 17 +- .../playground/preview/net-preview.tsx | 6 +- .../preview/net-preview/arc-view.tsx | 6 +- .../preview/net-preview/drawing.tsx | 13 +- .../preview/net-preview/node-view.tsx | 6 +- .../playground/scripts/screenshot.ts | 66 +--- .../semantics-view/evidence.test.ts | 77 ----- .../playground/semantics-view/evidence.ts | 44 --- .../semantics-view/question-card.css | 44 +-- .../semantics-view/question-card.tsx | 45 +-- .../semantics-view/question-list.css | 100 +----- .../semantics-view/question-list.tsx | 87 ++--- .../semantics-view/question-pages.test.tsx | 40 +++ .../semantics-view/semantics-intro.css | 32 -- .../semantics-view/semantics-intro.tsx | 31 -- .../semantics-view/semantics-view.tsx | 19 +- .../semantics-view/showing-view.css | 73 ---- .../semantics-view/showing-view.tsx | 47 --- .../playground/ui/back-link.css | 45 --- .../playground/ui/back-link.tsx | 29 -- .../playground/ui/code-excerpt.css | 46 --- .../playground/ui/code-excerpt.tsx | 28 -- .../playground/ui/excerpt.ts | 58 ---- .../playground/ui/switch.css | 65 ---- .../playground/ui/switch.tsx | 28 -- pocs/zeroth-playground/semantics/README.md | 83 +---- .../arc-weights-under-clocks/meta.ts | 10 - .../arc-weights-under-clocks/page.mdx | 17 - .../semantics/capacity-under-clocks/meta.ts | 12 - .../semantics/capacity-under-clocks/page.mdx | 18 - .../coloured-places-as-modules/meta.ts | 10 - .../coloured-places-as-modules/page.mdx | 20 -- .../coloured-tokens-under-clocks/meta.ts | 10 - .../coloured-tokens-under-clocks/page.mdx | 18 - .../semantics/false-pick-under-clocks/meta.ts | 16 - .../false-pick-under-clocks/page.mdx | 18 - .../meta.ts | 10 - .../page.mdx | 17 - pocs/zeroth-playground/semantics/intro.mdx | 5 - .../semantics/kernels-under-clocks/meta.ts | 10 - .../semantics/kernels-under-clocks/page.mdx | 18 - .../lra-flow-reads-its-state/meta.ts | 16 - .../lra-flow-reads-its-state/page.mdx | 23 -- .../semantics/marking-dependent-rates/meta.ts | 13 +- .../marking-dependent-rates/page.mdx | 20 +- .../one-mechanism-for-choices/meta.ts | 16 - .../one-mechanism-for-choices/page.mdx | 17 - .../semantics/picks-under-clocks/meta.ts | 16 - .../semantics/picks-under-clocks/page.mdx | 18 - .../read-and-inhibitor-arcs-modular/meta.ts | 12 - .../read-and-inhibitor-arcs-modular/page.mdx | 20 -- .../semantics/read-arcs-as-conflicts/meta.ts | 17 - .../semantics/read-arcs-as-conflicts/page.mdx | 18 - .../semantics/register.test.ts | 164 ++------- pocs/zeroth-playground/semantics/register.ts | 110 +----- .../room-freed-later-in-the-step/meta.ts | 12 - .../room-freed-later-in-the-step/page.mdx | 19 -- .../spn-and-lra-in-one-system/meta.ts | 14 +- .../spn-and-lra-in-one-system/page.mdx | 20 +- .../semantics/ties-under-clocks/meta.ts | 16 - .../semantics/ties-under-clocks/page.mdx | 17 - .../semantics/ties-under-coins/meta.ts | 21 -- .../semantics/ties-under-coins/page.mdx | 24 -- .../transition-or-places-decide/meta.ts | 13 +- .../transition-or-places-decide/page.mdx | 19 +- 183 files changed, 573 insertions(+), 7939 deletions(-) delete mode 100644 pocs/zeroth-playground/examples/__snapshots__/arcs/net.py delete mode 100644 pocs/zeroth-playground/examples/__snapshots__/capacity/net.py delete mode 100644 pocs/zeroth-playground/examples/__snapshots__/conflict-clocked/net.py delete mode 100644 pocs/zeroth-playground/examples/arcs/meta.ts delete mode 100644 pocs/zeroth-playground/examples/arcs/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/arcs/page.mdx delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.test.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/format.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.test.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.test.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/range-field.tsx delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/survival-plot.tsx delete mode 100644 pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/use-width.ts delete mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-draws.css delete mode 100644 pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx delete mode 100644 pocs/zeroth-playground/examples/bucket/meta.ts delete mode 100644 pocs/zeroth-playground/examples/bucket/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/bucket/page.mdx delete mode 100644 pocs/zeroth-playground/examples/capacity/meta.ts delete mode 100644 pocs/zeroth-playground/examples/capacity/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/capacity/page.mdx delete mode 100644 pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css delete mode 100644 pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.tsx delete mode 100644 pocs/zeroth-playground/examples/conflict-clocked/meta.ts delete mode 100644 pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml delete mode 100644 pocs/zeroth-playground/examples/conflict-clocked/page.mdx delete mode 100644 pocs/zeroth-playground/examples/cycle/components/one-step.css delete mode 100644 pocs/zeroth-playground/examples/cycle/components/one-step.tsx delete mode 100644 pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/compiler-guide.mdx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/compiler-view.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/focus-handoff.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-edge.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-graph/pipeline-node.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-layout.test.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/pipeline-layout.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/rich-text.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-card/sample-view.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-focus.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-overview.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-overview.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-ref.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-ref.tsx delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample.test.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/document-samples.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/excerpt.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/graph-samples.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/not-reached.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/rows.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stage-sample/trace-samples.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/stages.ts delete mode 100644 pocs/zeroth-playground/playground/compiler-view/strategy-table.css delete mode 100644 pocs/zeroth-playground/playground/compiler-view/strategy-table.tsx create mode 100644 pocs/zeroth-playground/playground/docs/example-net.css create mode 100644 pocs/zeroth-playground/playground/docs/example-net.tsx delete mode 100644 pocs/zeroth-playground/playground/docs/figures/figure.css delete mode 100644 pocs/zeroth-playground/playground/docs/figures/figure.tsx delete mode 100644 pocs/zeroth-playground/playground/playground-view/module-edits.test.ts delete mode 100644 pocs/zeroth-playground/playground/playground-view/module-edits.ts delete mode 100644 pocs/zeroth-playground/playground/semantics-view/evidence.test.ts delete mode 100644 pocs/zeroth-playground/playground/semantics-view/evidence.ts create mode 100644 pocs/zeroth-playground/playground/semantics-view/question-pages.test.tsx delete mode 100644 pocs/zeroth-playground/playground/semantics-view/semantics-intro.css delete mode 100644 pocs/zeroth-playground/playground/semantics-view/semantics-intro.tsx delete mode 100644 pocs/zeroth-playground/playground/semantics-view/showing-view.css delete mode 100644 pocs/zeroth-playground/playground/semantics-view/showing-view.tsx delete mode 100644 pocs/zeroth-playground/playground/ui/back-link.css delete mode 100644 pocs/zeroth-playground/playground/ui/back-link.tsx delete mode 100644 pocs/zeroth-playground/playground/ui/code-excerpt.css delete mode 100644 pocs/zeroth-playground/playground/ui/code-excerpt.tsx delete mode 100644 pocs/zeroth-playground/playground/ui/excerpt.ts delete mode 100644 pocs/zeroth-playground/playground/ui/switch.css delete mode 100644 pocs/zeroth-playground/playground/ui/switch.tsx delete mode 100644 pocs/zeroth-playground/semantics/arc-weights-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/arc-weights-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/capacity-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/capacity-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/coloured-places-as-modules/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/coloured-places-as-modules/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/coloured-tokens-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/false-pick-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/false-pick-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/immediate-transitions-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/intro.mdx delete mode 100644 pocs/zeroth-playground/semantics/kernels-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/kernels-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/lra-flow-reads-its-state/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/lra-flow-reads-its-state/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/one-mechanism-for-choices/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/one-mechanism-for-choices/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/picks-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/picks-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/read-and-inhibitor-arcs-modular/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/read-arcs-as-conflicts/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/read-arcs-as-conflicts/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/room-freed-later-in-the-step/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/room-freed-later-in-the-step/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/ties-under-clocks/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/ties-under-clocks/page.mdx delete mode 100644 pocs/zeroth-playground/semantics/ties-under-coins/meta.ts delete mode 100644 pocs/zeroth-playground/semantics/ties-under-coins/page.mdx diff --git a/pocs/zeroth-playground/AGENTS.md b/pocs/zeroth-playground/AGENTS.md index cdfa5cd4..10368aae 100644 --- a/pocs/zeroth-playground/AGENTS.md +++ b/pocs/zeroth-playground/AGENTS.md @@ -20,7 +20,7 @@ pnpm dev # http://localhost:5173, `#` opens an example pnpm lint:tsc # type-check, then check compiler/ on its own pnpm test # unit tests (vitest) pnpm build # dist/index.html, one self-contained file -pnpm screenshot # the listed examples and the Compiler view, into screenshots/ +pnpm screenshot # the examples and questions, into screenshots/ ZRTH_PATH=/path/to/zrth pnpm check:zrth # import every example's Python into zrth; not a gate ``` diff --git a/pocs/zeroth-playground/README.md b/pocs/zeroth-playground/README.md index f3ff0cdf..e523ad4a 100644 --- a/pocs/zeroth-playground/README.md +++ b/pocs/zeroth-playground/README.md @@ -1,33 +1,35 @@ -# zeroth-playground +# Zeroth compiler playground -This repository compiles a Petri net into Zeroth reactive modules, so that [zrth](https://github.com/zeroth-research/reactive-modules), Zeroth's reactive-modules library, can run and check it. The input is a net written in YAML as the Petri net IR; the output is Python that builds the modules with `zrth.sugar`. - -The playground shows the compiler at work on one screen: an example's documentation on the left, its IR in the middle, the Python module on the right and a preview of the net under them. It builds to one HTML file that opens from disk. +Compile a Petri net written in YAML into Zeroth Python. Five examples show one step, a shared token, rates as coins, rates as clocks, and a transition with two inputs. The Questions view holds three questions for Zeroth. ## Running -It needs Node 24 or later and pnpm. +Use Node 24 or later and pnpm. ```bash pnpm install pnpm dev ``` -`pnpm build` writes `dist/index.html`, one file with the JavaScript, the CSS, the fonts (Inter and JetBrains Mono) and the editor worker inlined, about 6.6 MB. It opens from `file://` and can be sent as an attachment. A comment at its top names every package and font it bundles, with the licence and the source of each. A URL hash names the example to open: `index.html#birth-death`. +Open `http://localhost:5173`. A hash selects an example, such as `#birth-death`. + +## Presenting + +The Examples view shows a short explanation, editable YAML, read-only Python, and the net. Point at a line or node to highlight its matching source and output. Options starts closed and shows only the module shape, coin time step, and conflict choice when they apply. Reset restores the example. -`pnpm test` runs the unit tests, `pnpm lint:tsc` type-checks, and `pnpm screenshot` opens the built file in Playwright's Chromium and screenshots the examples the picker lists, then the Compiler and Semantics views, at 1440×900 into `screenshots/`. Install that Chromium once with `pnpm exec playwright install chromium`. +Questions use short explanations and small embedded nets where useful. Their example links open the same net in the playground. `#semantics` opens the first question. -## What it does +`pnpm build` writes one self-contained `dist/index.html` that opens from `file://`. It includes scripts, styles, fonts, and the editor worker, with package licences in a comment at the top. -- **Views.** The IR editor has highlighting, completion of the IR's keys and places, and a hover that reads the IR trace. The module view lists the Python files and the diagnostics, each at the IR line of its item. The preview draws the net as an SVG laid out with elkjs. -- **Options beside the IR.** The Compiler options panel under the IR editor holds the compiler options; the IR carries none. An option that does not apply to the net is greyed, with the reason. Each example opens with its own options, and Reset restores them with the text. -- **Cross-highlighting.** Hovering a line in either editor lights the lines on the other side that name the same net item, and the item in the preview. Hovering a place or a transition in the preview lights its lines on both sides. -- **The example ladder.** The examples climb from nets compiled in steps, plain or with coins, to nets compiled in continuous time under clocks, each adding one idea and named by it. The picker in the header lists them. Each has a page; most end with open questions, each naming who can settle it: HASH, Zeroth or both. Two pages carry a CSS animation, and *Rates as clocks* has an explorer with a Δt slider, a draw and the coin's rate. -- **The Compiler view.** A switch in the header opens a guide, a graph of the pipeline's stages and a card per stage with a live sample from the open example. `#compiler`, or `#compiler/capacity`, opens it. -- **The Semantics view.** The questions still open between HASH and Zeroth on what a net means as modules, grouped by topic. Each one quotes the Python that shows it today and opens the example that shows it. `#semantics`, or `#semantics/ties-under-clocks`, opens it. +## Checks -The coloured example, Bucket, carries a code string and is refused with `code-not-parsed`: reading the code back needs a parser built on the TypeScript compiler, far past the single-file budget. The picker leaves it out; `#bucket` opens it, with the refusal and the page. +```bash +pnpm lint:tsc +pnpm test +pnpm build +pnpm screenshot +``` -## Working on it +Install screenshot Chromium once with `pnpm exec playwright install chromium`. Screenshots cover all examples and questions at 1440×900. -[AGENTS.md](AGENTS.md) is the entry point for people and agents: commands, rules, and links to the READMEs of [compiler/](compiler/README.md), [examples/](examples/README.md) and [playground/](playground/README.md), and to [compiler/mapping.md](compiler/mapping.md), the design shared with Zeroth. +[AGENTS.md](AGENTS.md) links the compiler, example, question, and app documentation. diff --git a/pocs/zeroth-playground/compiler/README.md b/pocs/zeroth-playground/compiler/README.md index f6be5509..8d23b1e0 100644 --- a/pocs/zeroth-playground/compiler/README.md +++ b/pocs/zeroth-playground/compiler/README.md @@ -377,6 +377,6 @@ The TypeScript in this folder is formatted as Prettier formats it at a width of [mapping.md](mapping.md) tracks these with the other open questions. -- The playground passes no `parseCode`, so a net with code strings, such as the Bucket example, is refused with `code-not-parsed`. A TypeScript parser does not fit the playground's one-file budget. +- The playground passes no `parseCode`, so a net with code strings, is refused with `code-not-parsed`. A TypeScript parser does not fit the playground's one-file budget. - Clock rates refuse a capacity (`clocks-capacity`) and an arc weight above one (`clocks-arc-weight`): SPN tests a count against zero only and moves one token at a time. So a stochastic net with either has no clocked output. Sections (b) and (c) of [mapping.md](mapping.md) hold the questions. - `pnpm check:zrth` checks that the output constructs, not that it runs. `zrth.simulate` takes no external but `t`, so the clocked output under `conflicts: nondet`, whose picks are externals, cannot run there. No run of the clocked output under `conflicts: sweep` is kept in the repository. diff --git a/pocs/zeroth-playground/compiler/mapping.md b/pocs/zeroth-playground/compiler/mapping.md index b1a6e9b7..b0a53092 100644 --- a/pocs/zeroth-playground/compiler/mapping.md +++ b/pocs/zeroth-playground/compiler/mapping.md @@ -1,6 +1,6 @@ # Mapping the Petri net IR to reactive modules -The design shared by HASH and Zeroth: how each part of a net maps to zrth, the options, the open questions and who can settle each. The questions on the semantics, what a net means as modules and what Zeroth can express, live in [../semantics/](../semantics/README.md) and the playground's Semantics view; each section below links its questions there. The tooling questions, on executors, seeds and numerics, are listed here alone. Settling a question updates its folder in `semantics/`, and this file where it is listed alone. Every mapping under coins reproduces one step of the net, as [README.md](README.md#one-step-of-the-net) defines it. +The design shared by HASH and Zeroth: how each part of a net maps to zrth, the options, the open questions and who can settle each. The questions on the semantics, what a net means as modules and what Zeroth can express, live in [../semantics/](../semantics/README.md) and the playground's Questions view; each section below links its questions there. The tooling questions, on executors, seeds and numerics, are listed here alone. Settling a question updates its folder in `semantics/`, and this file where it is listed alone. Every mapping under coins reproduces one step of the net, as [README.md](README.md#one-step-of-the-net) defines it. ## 1. Scope @@ -75,7 +75,6 @@ net = compose( Open questions: -- [A coloured place split into modules](../semantics/coloured-places-as-modules/page.mdx) (HASH) - When the spn line merges to `main`, so the `next` the compiler writes stops depending on the branch. (Zeroth) - [SPN and LRA modules in one system](../semantics/spn-and-lra-in-one-system/page.mdx) (Zeroth) @@ -128,11 +127,6 @@ Pool_0_x = Var(REAL) Pool_0_present = kept_Pool >= 1.0 ``` -Open questions: - -- [Capacity under clocks](../semantics/capacity-under-clocks/page.mdx) (Zeroth) -- [Room freed later in the step](../semantics/room-freed-later-in-the-step/page.mdx) (HASH) -- [Coloured tokens under clocks](../semantics/coloured-tokens-under-clocks/page.mdx) (Zeroth) ### (c) Transitions, enabling @@ -164,11 +158,6 @@ Read and inhibitor arcs under clocks (golden "tests read and inhibitor arcs, app fires_In = (clk_In == 0) & (B != 0) & (C == 0) ``` -Open questions: - -- [Arc weights under clocks](../semantics/arc-weights-under-clocks/page.mdx) (Zeroth) -- [Picks and choices under clocks](../semantics/picks-under-clocks/page.mdx) (Zeroth) -- [Read and inhibitor arcs in the modular shape](../semantics/read-and-inhibitor-arcs-modular/page.mdx), settled (HASH) ### (d) Transitions, firing and stochastic rates @@ -225,7 +214,6 @@ Open questions: - [The transition decides, or the places decide](../semantics/transition-or-places-decide/page.mdx) (HASH and Zeroth) - [Marking-dependent rates under clocks](../semantics/marking-dependent-rates/page.mdx) (Zeroth) -- [Immediate transitions under clocks](../semantics/immediate-transitions-under-clocks/page.mdx) (Zeroth) ### (e) Conflicts and non-determinism @@ -255,14 +243,6 @@ Coins (golden "has each transition in a conflict wait for an undriven pick under The interpreter reads an undriven pick as true, so the nondet trace equals the sweep trace. On a plain two-transition fork under coins (`lower/lower.test.ts`, "holds a transition whose pick is false and refuses another input left without a value"), holding `pick_TakeLeft` false for three steps ends at `{ Pool: 0, Left: 0, Right: 3 }`. -Open questions: - -- [A false pick under clocks](../semantics/false-pick-under-clocks/page.mdx) (HASH) -- [Ties under clocks](../semantics/ties-under-clocks/page.mdx) (HASH and Zeroth) -- [Ties under coins](../semantics/ties-under-coins/page.mdx) (HASH and Zeroth) -- [Read and inhibitor arcs as conflicts](../semantics/read-arcs-as-conflicts/page.mdx) (HASH) -- [One mechanism for external choices](../semantics/one-mechanism-for-choices/page.mdx) (HASH) -- [Picks and choices under clocks](../semantics/picks-under-clocks/page.mdx) (Zeroth) ### (f) Kernels and outputs @@ -289,9 +269,6 @@ Example (golden `drones`, `lower/monolithic/colour.test.ts`, kernel `const d = i A Gaussian draw in a rate, `Distribution.Gaussian(input.Hangar[0].battery, 4).map((v) => v + 1)`, lowers to `Hangar_0_battery + 4 * z_0 + 1` (`code/linear-code.test.ts`). -Open questions: - -- [Kernels under clocks](../semantics/kernels-under-clocks/page.mdx) (Zeroth) ### (g) Continuous dynamics and time @@ -318,7 +295,6 @@ The Euler step (golden `boiler`, `lower/monolithic/colour.test.ts`, `dt: 0.25`) Open questions: -- [An LRA flow that reads its own state](../semantics/lra-flow-reads-its-state/page.mdx) (Zeroth) - Seed and `dt` equivalence between HASH's own simulator and `simulate.py`: one stream per transition, or a lazy driver on HASH's side? (HASH, Zeroth) ### (h) Names, provenance and diagnostics diff --git a/pocs/zeroth-playground/examples/README.md b/pocs/zeroth-playground/examples/README.md index e4c89653..0c50cc76 100644 --- a/pocs/zeroth-playground/examples/README.md +++ b/pocs/zeroth-playground/examples/README.md @@ -1,123 +1,35 @@ # Examples -One folder per Petri net: its IR document, the compiler options it opens with, and a documentation page. The playground's picker lists the examples marked `listed`, and the tests compile every one. +Five small nets form the meeting demonstration. Each adds one idea. -## The ladder +| Order | Example | Opens with | +| --- | --- | --- | +| 10 | One step | One module | +| 20 | One shared token | Separate modules, transition order | +| 50 | Rates as coins | Separate modules, Δt = 0.5 | +| 60 | Rates as clocks | Clocked modules | +| 70 | Two inputs | Clocked modules, one input empty | -The examples climb a ladder of rungs. Each one adds one idea to the ones before it, and is named by that idea, then by the net it uses: "Rates as coins", using Birth–death. A page opens on the problem its net poses, quotes the lines the compiler writes for it, and stops. `LADDER` in `catalog.ts` lists the rungs: +## Files -| Rung | Holds | -| --- | --- | -| `steps` | nets compiled in steps, plain or with coins: what one step of the net means as modules | -| `clocks` | stochastic nets compiled in continuous time, in Zeroth's SPN theory | -| `colours` | coloured tokens; refused in the playground, which has no code parser, and unlisted | +Each folder holds `meta.ts`, `net.pn.yaml`, and `page.mdx`. Its name is the example id and URL hash. `catalog.ts` collects the folders and sorts by `order`. -| Order | Example | Net | Opens with | What you learn | -| --- | --- | --- | --- | --- | -| 10 | One step as a module | Cycle | monolithic | A step takes at once and lands at the end, as one `next` over an Int per place, or as one module per transition and place. | -| 20 | Who gets the token | Conflict | modular, `conflicts: nondet` | Record order settles a shared place; a pick per contender leaves it open, so every resolution is a run. | -| 30 | A capped place | Capacity | modular | `fill_Buffer` counts a token that lands at the end of the step; room freed later in the step does not count. | -| 40 | What an arc tests | Arcs | modular | A weight takes w tokens, a read arc tests without taking, an inhibitor arc tests emptiness. | -| 50 | Rates as coins | Birth–death | modular, `dt: 0.5` | A rate becomes a draw tested against e^(−λ·Δt) each step; the places become Real in LRA. | -| 60 | Rates as clocks | Birth–death | `rates: clock` | A rate becomes a clock and an event in SPN; a coin's rate stays below λ. | -| 70 | Two inputs under clocks | Café queue | `rates: clock` | The transition pauses its clock while an input place is empty, where Zeroth's model lets the places decide. | -| 80 | Conflicts under clocks | Conflict with rates | `rates: clock`, `conflicts: nondet` | A conflict is a race between clocks; a false pick stops time, and a tie produces twice. | -| 90 | Code strings are refused, unlisted | Bucket | monolithic | A rate written as code is refused with `code-not-parsed` until a code parser is passed. | +`meta.ts` defines the feature heading, net title, order, and compiler options. The picker lists every example. Compiler options stay outside the YAML. Reset restores the text and options. -The picker lists the listed examples rung by rung, sorted by `order`, and skips a rung with none. Put a new example where its idea builds on the ones before it, and only when no example already teaches that idea. +## Pages -## One folder per example +Use a few sentences, one equation or short Python excerpt, and one thing to change. Explain the behaviour before naming implementation details. Write maths as `$...$` or `$$...$$`; it renders to SVG at build time. -``` -birth-death/ - meta.ts what the picker and the page header show, the rung, the order, the options - net.pn.yaml the IR document - page.mdx the documentation page - components/coin-draws.tsx a drawing only this page uses (optional) - components/coin-draws.css its styles, imported by the component -``` +A page can link a meeting question with ``. Keep questions in [semantics/](../semantics/README.md). -The folder name is the id: the picker's key, the page's key and the URL hash, `#birth-death`. `catalog.ts` collects the folders with `import.meta.glob`, so nothing else registers an example. - -`meta.ts` default-exports an `ExampleMeta`, defined in `catalog.ts`: - -| Field | Value | -| --- | --- | -| `feature` | what the example tackles: its name in the picker and its page heading | -| `title` | the net's name, shown above the heading | -| `summary` | one line on what it shows, in the picker | -| `rung` | `steps`, `clocks` or `colours` | -| `order` | its place up the ladder; examples sort by it, in steps of 10 | -| `listed` | `false` keeps it out of the picker; its hash still opens it | -| `options` | the compiler options it opens with | - -## Options - -The IR carries no options. An example's options sit in `meta.ts`, as a `CompilerOptions` object, and the playground's options panel edits them from there. Reset restores them. - -- An example opens with the options that show its idea, and the page discusses each one. Cycle opens in the default monolithic shape, so the one-class step comes first. The other plain and coin examples open modular, `{ shape: "modular" }`, and the clock examples under `rates: "clock"`. Bucket, which no shape compiles here, opens with none. -- Add another option only where the page discusses it, such as `dt: 0.5` on *Rates as coins*, or `conflicts: "nondet"` on *Who gets the token*. -- Every option an example opens with applies to its net, so the panel shows it and `compile` raises no `option-not-applicable` warning. `examples.test.ts` checks it. - -[compiler/README.md](../compiler/README.md#options) lists the options and the nets each applies to. +The shared MDX components include `ExampleNet`, `Question`, and fenced code. `` embeds the existing net renderer and a link to that example. ## Adding an example -1. Check that no example already teaches the idea. Copy a folder from the same rung and rename it to the new id. -2. Edit `meta.ts`. Pick an `order` between the examples it builds on and the ones that build on it. -3. Replace `net.pn.yaml`. Write it in the block format the other examples use, one key per line and code as `|` blocks. -4. Rewrite `page.mdx`. Put a figure it alone uses in its `components/` folder. -5. Add the id to the expected outcomes in `examples.test.ts`. -6. Run `pnpm test` once. If the example compiles, this writes its Python to `__snapshots__//net.py`; read it. -7. Open `#` in `pnpm dev` or the build, and run `pnpm screenshot`. - -## Page structure - -````mdx -import { CoinDraws } from "./components/coin-draws"; - -The problem the example shows, in the first two sentences. Then what the -compiler writes for it, quoting the few Python lines that matter: - -```python -return X(u_Birth) >= 0.36787944117144233 -``` - -
    - -
    - -### Options - -- **Marking** on Int keeps the places Int and ... - -### Open questions - - -```` - -- The documentation view writes the net's name and the heading from `meta.ts`; the page starts with its first paragraph. -- The documentation view passes `Figure` and `Question` to every page as MDX components, from `playground/docs/page-components.ts`. A page uses them without importing them, and imports only its own `./components/`. It also renders fenced code through `CodeBlock`, which wraps a long line under a hanging indent, so quote a line of Python in a fenced block when it is longer than about 25 characters: inline code does not wrap. -- Open with the problem, not with the net or the options, so a reader gets it at a glance. A page is about a hundred words: the problem in one or two sentences, one quoted answer, and at most two options. Name the IR items and the Python names the reader will see. Name another example by its feature, in italics: *Rates as clocks*. -- **Options** lists only the options that change this net's output, never one the panel greys out, and not the shape the example opens with. Leave the section out when the body already covers them. -- Only the first page, Cycle, ends with one line on what pointing at a line or a node does. -- An open question lives in [semantics/](../semantics/README.md), one folder per question, and a page only references it: `` renders a card with its owner, its status and a way into the Semantics view. An id the register does not hold fails the page render test. Each question sits on the one page where it matters most. -- A refused example says why under `### Refused here`, and what the compiler would write with a code parser. -- Maths is written as `$...$` or `$$...$$` and rendered to SVG at build time. Keep it short. -- A figure helps where a picture explains the mapping. Put the drawing in the example's `components/` folder, and wrap it in `
    ` on the page. `Figure` draws the frame and the caption; the frame's CSS is in `playground/docs/figures/figure.css`. - -## Figures - -Every React component of an example sits in its `components/` folder, with its CSS beside it, and imports nothing from `playground/`. Its CSS uses the playground's tokens, such as `var(--accent)` or `var(--fs-xs)`, from `playground/theme/tokens.css`. A drawing is small, minimal and correct about the step it shows. - -- Text keeps a fixed size whatever the panel's width: the docs panel is about 200 px wide in a 1000 px window and 300 px in a 1440 px one. Lay a drawing out in HTML and CSS, or in an SVG without a `viewBox`, placed in percentages or drawn at the width a `ResizeObserver` measures. Never scale text with a `viewBox`. -- `Figure` is a size container, so a drawing can change its layout with `@container` queries. -- Animate with CSS alone. Under `prefers-reduced-motion: reduce`, stop every animation and show one frame that still makes the point. -- Mark a state in text as well as in colour, such as "fires" or "waits". -- Check a figure headlessly at 1440×900 and 1000×800, at several animation times and with reduced motion, as [playground/README.md](../playground/README.md#checking-the-ui) describes. - -## Tests +1. Add a folder with the three files above. +2. Keep the net small and give it one purpose. +3. Add its expected outcome in `examples.test.ts`. +4. Run `pnpm test -u` and read the generated Python snapshot. +5. Run the project checks and `pnpm screenshot`. -- `catalog.test.ts` checks that every folder holds a `meta.ts`, a `net.pn.yaml` and a `page.mdx`, and that the examples climb the ladder rung by rung, each at its own `order`. -- `playground/docs/example-pages.test.tsx` renders every page with the components the documentation view passes. MDX is not type-checked, so this test catches a component a page uses but neither imports nor is passed, and a `Question` whose id the register does not hold. -- `examples.test.ts` compiles each example under the options it opens with. It checks the outcome table (compiles, or the codes it is refused with), that no option is dropped, and each Python file against its snapshot in `__snapshots__//`, one folder per example that compiles. A change that alters the output updates the snapshots with `pnpm test -u` and lists the diff in its commit. +The tests check folder completeness, distinct orders, successful compilation, applicable defaults, and generated Python snapshots. The page render test checks MDX component and question references. diff --git a/pocs/zeroth-playground/examples/__snapshots__/arcs/net.py b/pocs/zeroth-playground/examples/__snapshots__/arcs/net.py deleted file mode 100644 index d492eb57..00000000 --- a/pocs/zeroth-playground/examples/__snapshots__/arcs/net.py +++ /dev/null @@ -1,73 +0,0 @@ -from zrth import LIA, Bool, Int, Var -from zrth import Module as compose -from zrth.sugar import Module, X, ite - -INT = Int([1, 1]) -BOOL = Bool([1, 1]) - -Parts = Var(INT) -Power = Var(INT) -Jam = Var(INT) -Products = Var(INT) - -fire_Join = Var(BOOL) # Join fires this step - - -class Transition_Join(Module): - """Join: 2 Parts, read Power, inhibitor Jam -> Products""" - - def init(self, Parts, Power, Jam): - return False - - def next(self, fire_Join, Parts, Power, Jam): - return (Parts >= 2) & (Power >= 1) & (Jam < 1) - - -class Place_Parts(Module): - """Parts: taken by Join""" - - def init(self, fire_Join): - return 5 - - def next(self, Parts, fire_Join): - Parts = ite(X(fire_Join), Parts - 2, Parts) # Join takes 2 - return Parts - - -class Place_Power(Module): - """Power: no transition moves its tokens""" - - def init(self): - return 1 - - def next(self, Power): - return Power - - -class Place_Jam(Module): - """Jam: no transition moves its tokens""" - - def init(self): - return 0 - - def next(self, Jam): - return Jam - - -class Place_Products(Module): - """Products: added by Join""" - - def init(self, fire_Join): - return 0 - - def next(self, Products, fire_Join): - Products = ite(X(fire_Join), Products + 1, Products) # Join adds 1 - return Products - - -transition_Join = Transition_Join(theory=LIA, ctrl=(fire_Join,), extl=(Parts, Power, Jam)) -place_Parts = Place_Parts(theory=LIA, ctrl=(Parts,), extl=(fire_Join,)) -place_Power = Place_Power(theory=LIA, ctrl=(Power,)) -place_Jam = Place_Jam(theory=LIA, ctrl=(Jam,)) -place_Products = Place_Products(theory=LIA, ctrl=(Products,), extl=(fire_Join,)) -net = compose(transition_Join, place_Parts, place_Power, place_Jam, place_Products) diff --git a/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py b/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py index a11cfe87..f1dce567 100644 --- a/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py +++ b/pocs/zeroth-playground/examples/__snapshots__/cafe-queue/net.py @@ -7,120 +7,67 @@ Waiting = Var(Nat()) FreeStaff = Var(Nat()) Serving = Var(Nat()) -Served = Var(Nat()) -clk_Arrive = Var(Clock()) # time left until Arrive fires clk_BeginService = Var(Clock()) # time left until BeginService fires -clk_FinishService = Var(Clock()) # time left until FinishService fires -ev_Arrive = Var(Event()) # toggles when Arrive fires ev_BeginService = Var(Event()) # toggles when BeginService fires -ev_FinishService = Var(Event()) # toggles when FinishService fires - - -class Transition_Arrive(Module): - """Arrive: nothing -> Waiting, at rate 1.2""" - - def init(self, t): - return exp(1.2), False - - def next(self, clk_Arrive, ev_Arrive, t): - fires_Arrive = clk_Arrive == 0 - return ite(fires_Arrive, exp(1.2), clk_Arrive), ite(fires_Arrive, ~ev_Arrive, None) - - def flow(self, clk_Arrive, ev_Arrive, t): - return ite(clk_Arrive >= 0, -1 * d(t), None), 0 class Transition_BeginService(Module): - """BeginService: Waiting, FreeStaff -> Serving, at rate 0.4""" + """BeginService: Waiting, FreeStaff -> Serving, at rate 1""" def init(self, Waiting, FreeStaff, t): - return exp(0.4), False + return exp(1.0), False def next(self, clk_BeginService, ev_BeginService, Waiting, FreeStaff, t): fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) - return ite(fires_BeginService, exp(0.4), clk_BeginService), ite(fires_BeginService, ~ev_BeginService, None) + return ite(fires_BeginService, exp(1.0), clk_BeginService), ite(fires_BeginService, ~ev_BeginService, None) def flow(self, clk_BeginService, ev_BeginService, Waiting, FreeStaff, t): return ite(clk_BeginService >= 0, ite((Waiting != 0) & (FreeStaff != 0), -1 * d(t), 0 * d(t)), None), 0 -class Transition_FinishService(Module): - """FinishService: Serving -> Served, FreeStaff, at rate 0.5""" - - def init(self, Serving, t): - return exp(0.5), False - - def next(self, clk_FinishService, ev_FinishService, Serving, t): - fires_FinishService = (clk_FinishService == 0) & (Serving != 0) - return ite(fires_FinishService, exp(0.5), clk_FinishService), ite(fires_FinishService, ~ev_FinishService, None) - - def flow(self, clk_FinishService, ev_FinishService, Serving, t): - return ite(clk_FinishService >= 0, ite(Serving != 0, -1 * d(t), 0 * d(t)), None), 0 - - class Place_Waiting(Module): - """Waiting: added by Arrive, taken by BeginService""" + """Waiting: taken by BeginService""" - def init(self, ev_Arrive, ev_BeginService): - return 3 + def init(self, ev_BeginService): + return 1 - def next(self, Waiting, ev_Arrive, ev_BeginService): - fired_Arrive = fired(ev_Arrive) + def next(self, Waiting, ev_BeginService): fired_BeginService = fired(ev_BeginService) - return ite(fired_Arrive & ~fired_BeginService, Waiting + 1, ite(fired_BeginService & ~fired_Arrive & (Waiting != 0), Waiting - 1, Waiting)) + return ite(fired_BeginService & (Waiting != 0), Waiting - 1, Waiting) class Place_FreeStaff(Module): - """FreeStaff: added by FinishService, taken by BeginService""" + """FreeStaff: taken by BeginService""" - def init(self, ev_FinishService, ev_BeginService): - return 2 + def init(self, ev_BeginService): + return 0 - def next(self, FreeStaff, ev_FinishService, ev_BeginService): - fired_FinishService = fired(ev_FinishService) + def next(self, FreeStaff, ev_BeginService): fired_BeginService = fired(ev_BeginService) - return ite(fired_FinishService & ~fired_BeginService, FreeStaff + 1, ite(fired_BeginService & ~fired_FinishService & (FreeStaff != 0), FreeStaff - 1, FreeStaff)) + return ite(fired_BeginService & (FreeStaff != 0), FreeStaff - 1, FreeStaff) class Place_Serving(Module): - """Serving: added by BeginService, taken by FinishService""" + """Serving: added by BeginService""" - def init(self, ev_BeginService, ev_FinishService): + def init(self, ev_BeginService): return 0 - def next(self, Serving, ev_BeginService, ev_FinishService): + def next(self, Serving, ev_BeginService): fired_BeginService = fired(ev_BeginService) - fired_FinishService = fired(ev_FinishService) - return ite(fired_BeginService & ~fired_FinishService, Serving + 1, ite(fired_FinishService & ~fired_BeginService & (Serving != 0), Serving - 1, Serving)) - - -class Place_Served(Module): - """Served: added by FinishService""" - - def init(self, ev_FinishService): - return 0 - - def next(self, Served, ev_FinishService): - fired_FinishService = fired(ev_FinishService) - return ite(fired_FinishService, Served + 1, Served) + return ite(fired_BeginService, Serving + 1, Serving) -transition_Arrive = Transition_Arrive(theory=SPN, ctrl=(clk_Arrive, ev_Arrive), extl=(t,)) transition_BeginService = Transition_BeginService(theory=SPN, ctrl=(clk_BeginService, ev_BeginService), extl=(Waiting, FreeStaff, t)) -transition_FinishService = Transition_FinishService(theory=SPN, ctrl=(clk_FinishService, ev_FinishService), extl=(Serving, t)) -place_Waiting = Place_Waiting(theory=SPN, ctrl=(Waiting,), extl=(ev_Arrive, ev_BeginService)) -place_FreeStaff = Place_FreeStaff(theory=SPN, ctrl=(FreeStaff,), extl=(ev_FinishService, ev_BeginService)) -place_Serving = Place_Serving(theory=SPN, ctrl=(Serving,), extl=(ev_BeginService, ev_FinishService)) -place_Served = Place_Served(theory=SPN, ctrl=(Served,), extl=(ev_FinishService,)) +place_Waiting = Place_Waiting(theory=SPN, ctrl=(Waiting,), extl=(ev_BeginService,)) +place_FreeStaff = Place_FreeStaff(theory=SPN, ctrl=(FreeStaff,), extl=(ev_BeginService,)) +place_Serving = Place_Serving(theory=SPN, ctrl=(Serving,), extl=(ev_BeginService,)) net = compose( - transition_Arrive, transition_BeginService, - transition_FinishService, place_Waiting, place_FreeStaff, place_Serving, - place_Served, - hide={clk_Arrive, clk_BeginService, clk_FinishService}, + hide={clk_BeginService}, ) diff --git a/pocs/zeroth-playground/examples/__snapshots__/capacity/net.py b/pocs/zeroth-playground/examples/__snapshots__/capacity/net.py deleted file mode 100644 index 57fc3f37..00000000 --- a/pocs/zeroth-playground/examples/__snapshots__/capacity/net.py +++ /dev/null @@ -1,111 +0,0 @@ -from zrth import LIA, Bool, Int, Var -from zrth import Module as compose -from zrth.sugar import Module, X, ite - -INT = Int([1, 1]) -BOOL = Bool([1, 1]) - -LeftSource = Var(INT) -RightSource = Var(INT) -Buffer = Var(INT) -Sink = Var(INT) - -fire_PutLeft = Var(BOOL) # PutLeft fires this step -fire_PutRight = Var(BOOL) # PutRight fires this step -fire_Take = Var(BOOL) # Take fires this step - - -class Transition_PutLeft(Module): - """PutLeft: LeftSource -> Buffer""" - - def init(self, LeftSource, Buffer): - return False - - def next(self, fire_PutLeft, LeftSource, Buffer): - return (LeftSource >= 1) & (Buffer + 1 <= 2) - - -class Transition_PutRight(Module): - """PutRight: RightSource -> Buffer""" - - def init(self, RightSource, Buffer, fire_PutLeft): - return False - - def next(self, fire_PutRight, RightSource, Buffer, fire_PutLeft): - fill_Buffer = Buffer - fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) # PutLeft added 1 - return (RightSource >= 1) & (fill_Buffer + 1 <= 2) - - -class Transition_Take(Module): - """Take: Buffer -> Sink""" - - def init(self, Buffer): - return False - - def next(self, fire_Take, Buffer): - return Buffer >= 1 - - -class Place_LeftSource(Module): - """LeftSource: taken by PutLeft""" - - def init(self, fire_PutLeft): - return 5 - - def next(self, LeftSource, fire_PutLeft): - LeftSource = ite(X(fire_PutLeft), LeftSource - 1, LeftSource) # PutLeft takes 1 - return LeftSource - - -class Place_RightSource(Module): - """RightSource: taken by PutRight""" - - def init(self, fire_PutRight): - return 5 - - def next(self, RightSource, fire_PutRight): - RightSource = ite(X(fire_PutRight), RightSource - 1, RightSource) # PutRight takes 1 - return RightSource - - -class Place_Buffer(Module): - """Buffer: taken by Take, added by PutLeft, PutRight""" - - def init(self, fire_PutLeft, fire_PutRight, fire_Take): - return 0 - - def next(self, Buffer, fire_PutLeft, fire_PutRight, fire_Take): - Buffer = ite(X(fire_PutLeft), Buffer + 1, Buffer) # PutLeft adds 1 - Buffer = ite(X(fire_PutRight), Buffer + 1, Buffer) # PutRight adds 1 - Buffer = ite(X(fire_Take), Buffer - 1, Buffer) # Take takes 1 - return Buffer - - -class Place_Sink(Module): - """Sink: added by Take""" - - def init(self, fire_Take): - return 0 - - def next(self, Sink, fire_Take): - Sink = ite(X(fire_Take), Sink + 1, Sink) # Take adds 1 - return Sink - - -transition_PutLeft = Transition_PutLeft(theory=LIA, ctrl=(fire_PutLeft,), extl=(LeftSource, Buffer)) -transition_PutRight = Transition_PutRight(theory=LIA, ctrl=(fire_PutRight,), extl=(RightSource, Buffer, fire_PutLeft)) -transition_Take = Transition_Take(theory=LIA, ctrl=(fire_Take,), extl=(Buffer,)) -place_LeftSource = Place_LeftSource(theory=LIA, ctrl=(LeftSource,), extl=(fire_PutLeft,)) -place_RightSource = Place_RightSource(theory=LIA, ctrl=(RightSource,), extl=(fire_PutRight,)) -place_Buffer = Place_Buffer(theory=LIA, ctrl=(Buffer,), extl=(fire_PutLeft, fire_PutRight, fire_Take)) -place_Sink = Place_Sink(theory=LIA, ctrl=(Sink,), extl=(fire_Take,)) -net = compose( - transition_PutLeft, - transition_PutRight, - transition_Take, - place_LeftSource, - place_RightSource, - place_Buffer, - place_Sink, -) diff --git a/pocs/zeroth-playground/examples/__snapshots__/conflict-clocked/net.py b/pocs/zeroth-playground/examples/__snapshots__/conflict-clocked/net.py deleted file mode 100644 index 1d0ac692..00000000 --- a/pocs/zeroth-playground/examples/__snapshots__/conflict-clocked/net.py +++ /dev/null @@ -1,115 +0,0 @@ -from zrth import SPN, Bool, Clock, Event, Nat, Var -from zrth import Module as compose -from zrth.sugar import Module, d, ite, fired, exp - -t = Var(Clock()) # the time reference - -Pool = Var(Nat()) -Left = Var(Nat()) -Right = Var(Nat()) - -clk_TakeLeft = Var(Clock()) # time left until TakeLeft fires -clk_TakeRight = Var(Clock()) # time left until TakeRight fires -clk_Return = Var(Clock()) # time left until Return fires - -ev_TakeLeft = Var(Event()) # toggles when TakeLeft fires -ev_TakeRight = Var(Event()) # toggles when TakeRight fires -ev_Return = Var(Event()) # toggles when Return fires - -pick_TakeLeft = Var(Bool([1, 1])) # the environment lets TakeLeft fire when its clock expires -pick_TakeRight = Var(Bool([1, 1])) # the environment lets TakeRight fire when its clock expires - - -class Transition_TakeLeft(Module): - """TakeLeft: Pool -> Left, at rate 1""" - - def init(self, Pool, pick_TakeLeft, t): - return exp(1.0), False - - def next(self, clk_TakeLeft, ev_TakeLeft, Pool, pick_TakeLeft, t): - fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft - return ite(fires_TakeLeft, exp(1.0), clk_TakeLeft), ite(fires_TakeLeft, ~ev_TakeLeft, None) - - def flow(self, clk_TakeLeft, ev_TakeLeft, Pool, pick_TakeLeft, t): - return ite(clk_TakeLeft >= 0, ite(Pool != 0, -1 * d(t), 0 * d(t)), None), 0 - - -class Transition_TakeRight(Module): - """TakeRight: Pool -> Right, at rate 2""" - - def init(self, Pool, pick_TakeRight, t): - return exp(2.0), False - - def next(self, clk_TakeRight, ev_TakeRight, Pool, pick_TakeRight, t): - fires_TakeRight = (clk_TakeRight == 0) & (Pool != 0) & pick_TakeRight - return ite(fires_TakeRight, exp(2.0), clk_TakeRight), ite(fires_TakeRight, ~ev_TakeRight, None) - - def flow(self, clk_TakeRight, ev_TakeRight, Pool, pick_TakeRight, t): - return ite(clk_TakeRight >= 0, ite(Pool != 0, -1 * d(t), 0 * d(t)), None), 0 - - -class Transition_Return(Module): - """Return: Left -> Pool, at rate 0.5""" - - def init(self, Left, t): - return exp(0.5), False - - def next(self, clk_Return, ev_Return, Left, t): - fires_Return = (clk_Return == 0) & (Left != 0) - return ite(fires_Return, exp(0.5), clk_Return), ite(fires_Return, ~ev_Return, None) - - def flow(self, clk_Return, ev_Return, Left, t): - return ite(clk_Return >= 0, ite(Left != 0, -1 * d(t), 0 * d(t)), None), 0 - - -class Place_Pool(Module): - """Pool: added by Return, taken by TakeLeft, TakeRight""" - - def init(self, ev_Return, ev_TakeLeft, ev_TakeRight): - return 3 - - def next(self, Pool, ev_Return, ev_TakeLeft, ev_TakeRight): - fired_Return = fired(ev_Return) - fired_TakeLeft = fired(ev_TakeLeft) - fired_TakeRight = fired(ev_TakeRight) - return ite(fired_Return & ~fired_TakeLeft & ~fired_TakeRight, Pool + 1, ite(fired_TakeLeft & ~fired_Return & ~fired_TakeRight & (Pool != 0), Pool - 1, ite(fired_TakeRight & ~fired_Return & ~fired_TakeLeft & (Pool != 0), Pool - 1, Pool))) - - -class Place_Left(Module): - """Left: added by TakeLeft, taken by Return""" - - def init(self, ev_TakeLeft, ev_Return): - return 0 - - def next(self, Left, ev_TakeLeft, ev_Return): - fired_TakeLeft = fired(ev_TakeLeft) - fired_Return = fired(ev_Return) - return ite(fired_TakeLeft & ~fired_Return, Left + 1, ite(fired_Return & ~fired_TakeLeft & (Left != 0), Left - 1, Left)) - - -class Place_Right(Module): - """Right: added by TakeRight""" - - def init(self, ev_TakeRight): - return 0 - - def next(self, Right, ev_TakeRight): - fired_TakeRight = fired(ev_TakeRight) - return ite(fired_TakeRight, Right + 1, Right) - - -transition_TakeLeft = Transition_TakeLeft(theory=SPN, ctrl=(clk_TakeLeft, ev_TakeLeft), extl=(Pool, pick_TakeLeft, t)) -transition_TakeRight = Transition_TakeRight(theory=SPN, ctrl=(clk_TakeRight, ev_TakeRight), extl=(Pool, pick_TakeRight, t)) -transition_Return = Transition_Return(theory=SPN, ctrl=(clk_Return, ev_Return), extl=(Left, t)) -place_Pool = Place_Pool(theory=SPN, ctrl=(Pool,), extl=(ev_Return, ev_TakeLeft, ev_TakeRight)) -place_Left = Place_Left(theory=SPN, ctrl=(Left,), extl=(ev_TakeLeft, ev_Return)) -place_Right = Place_Right(theory=SPN, ctrl=(Right,), extl=(ev_TakeRight,)) -net = compose( - transition_TakeLeft, - transition_TakeRight, - transition_Return, - place_Pool, - place_Left, - place_Right, - hide={clk_TakeLeft, clk_TakeRight, clk_Return}, -) diff --git a/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py b/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py index 4e56f535..037a35fc 100644 --- a/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py +++ b/pocs/zeroth-playground/examples/__snapshots__/conflict/net.py @@ -9,68 +9,52 @@ Left = Var(INT) Right = Var(INT) -pick_TakeLeft = Var(BOOL) # the environment lets TakeLeft fire this step -pick_TakeRight = Var(BOOL) # the environment lets TakeRight fire this step - fire_TakeLeft = Var(BOOL) # TakeLeft fires this step fire_TakeRight = Var(BOOL) # TakeRight fires this step -fire_Return = Var(BOOL) # Return fires this step class Transition_TakeLeft(Module): """TakeLeft: Pool -> Left""" - def init(self, Pool, pick_TakeLeft): + def init(self, Pool): return False - def next(self, fire_TakeLeft, Pool, pick_TakeLeft): - return (Pool >= 1) & X(pick_TakeLeft) + def next(self, fire_TakeLeft, Pool): + return Pool >= 1 class Transition_TakeRight(Module): """TakeRight: Pool -> Right""" - def init(self, Pool, fire_TakeLeft, pick_TakeRight): + def init(self, Pool, fire_TakeLeft): return False - def next(self, fire_TakeRight, Pool, fire_TakeLeft, pick_TakeRight): + def next(self, fire_TakeRight, Pool, fire_TakeLeft): avail_Pool = Pool avail_Pool = ite(X(fire_TakeLeft), avail_Pool - 1, avail_Pool) # TakeLeft took 1 - return (avail_Pool >= 1) & X(pick_TakeRight) - - -class Transition_Return(Module): - """Return: Left -> Pool""" - - def init(self, Left): - return False - - def next(self, fire_Return, Left): - return Left >= 1 + return avail_Pool >= 1 class Place_Pool(Module): - """Pool: taken by TakeLeft, TakeRight, added by Return""" + """Pool: taken by TakeLeft, TakeRight""" - def init(self, fire_TakeLeft, fire_TakeRight, fire_Return): - return 3 + def init(self, fire_TakeLeft, fire_TakeRight): + return 1 - def next(self, Pool, fire_TakeLeft, fire_TakeRight, fire_Return): + def next(self, Pool, fire_TakeLeft, fire_TakeRight): Pool = ite(X(fire_TakeLeft), Pool - 1, Pool) # TakeLeft takes 1 Pool = ite(X(fire_TakeRight), Pool - 1, Pool) # TakeRight takes 1 - Pool = ite(X(fire_Return), Pool + 1, Pool) # Return adds 1 return Pool class Place_Left(Module): - """Left: taken by Return, added by TakeLeft""" + """Left: added by TakeLeft""" - def init(self, fire_TakeLeft, fire_Return): + def init(self, fire_TakeLeft): return 0 - def next(self, Left, fire_TakeLeft, fire_Return): + def next(self, Left, fire_TakeLeft): Left = ite(X(fire_TakeLeft), Left + 1, Left) # TakeLeft adds 1 - Left = ite(X(fire_Return), Left - 1, Left) # Return takes 1 return Left @@ -85,16 +69,14 @@ def next(self, Right, fire_TakeRight): return Right -transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool, pick_TakeLeft)) -transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft, pick_TakeRight)) -transition_Return = Transition_Return(theory=LIA, ctrl=(fire_Return,), extl=(Left,)) -place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight, fire_Return)) -place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft, fire_Return)) +transition_TakeLeft = Transition_TakeLeft(theory=LIA, ctrl=(fire_TakeLeft,), extl=(Pool,)) +transition_TakeRight = Transition_TakeRight(theory=LIA, ctrl=(fire_TakeRight,), extl=(Pool, fire_TakeLeft)) +place_Pool = Place_Pool(theory=LIA, ctrl=(Pool,), extl=(fire_TakeLeft, fire_TakeRight)) +place_Left = Place_Left(theory=LIA, ctrl=(Left,), extl=(fire_TakeLeft,)) place_Right = Place_Right(theory=LIA, ctrl=(Right,), extl=(fire_TakeRight,)) net = compose( transition_TakeLeft, transition_TakeRight, - transition_Return, place_Pool, place_Left, place_Right, diff --git a/pocs/zeroth-playground/examples/arcs/meta.ts b/pocs/zeroth-playground/examples/arcs/meta.ts deleted file mode 100644 index d9592ff7..00000000 --- a/pocs/zeroth-playground/examples/arcs/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "What an arc tests", - title: "Arcs", - summary: "An arc that takes two tokens, one that reads a place, one that needs it empty.", - rung: "steps", - order: 40, - listed: true, - options: { shape: "modular" }, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/arcs/net.pn.yaml b/pocs/zeroth-playground/examples/arcs/net.pn.yaml deleted file mode 100644 index 10c2b3ef..00000000 --- a/pocs/zeroth-playground/examples/arcs/net.pn.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: arcs -kind: plain - -places: - Parts: - Power: - Jam: - Products: - -transitions: - Join: - inputs: - Parts: - weight: 2 - Power: - kind: read - Jam: - kind: inhibitor - outputs: - Products: - -marking: - Parts: 5 - Power: 1 diff --git a/pocs/zeroth-playground/examples/arcs/page.mdx b/pocs/zeroth-playground/examples/arcs/page.mdx deleted file mode 100644 index 09cad0af..00000000 --- a/pocs/zeroth-playground/examples/arcs/page.mdx +++ /dev/null @@ -1,19 +0,0 @@ -`Join` takes two `Parts`, reads `Power` without taking from it, and fires only while `Jam` is empty. Each kind of arc tests something else: - -```python -return (Parts >= 2) & (Power >= 1) & (Jam < 1) -``` - -- A weight w tests `P >= w` and takes w tokens: `Place_Parts` applies `Parts - 2`. -- A read arc tests `P >= w` and takes nothing. -- An inhibitor arc tests `P < w`. - -Add `Jam: 1` to the marking, and `Join` never fires. Under clocks a read arc tests `P != 0` and an inhibitor arc `P == 0`, while a weight above 1 is refused: an SPN event moves one token per arc. - -### Open questions - - - - - - diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css deleted file mode 100644 index 81111e74..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.css +++ /dev/null @@ -1,184 +0,0 @@ -/* -- Birth's coin against its clock: a Δt slider, the survival plot, a draw -- */ - -.explorer { - display: flex; - flex-direction: column; - gap: 0.6rem; - padding: 0.75rem 0.8rem 0.8rem; - border: 1px solid var(--hair-2); - border-radius: var(--r-xs); - background: var(--paper-2); -} - -.explorer__field { - display: flex; - flex-direction: column; - gap: 0.15rem; -} - -.explorer__field-label { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 0.5rem; - color: var(--ink); - font-size: var(--fs-md); -} - -.explorer__field-value { - color: var(--accent); - font-family: var(--mono); - font-size: 0.75rem; - font-variant-numeric: tabular-nums; -} - -.explorer__range { - width: 100%; - margin: 0; - accent-color: var(--accent); -} - -.explorer__range:focus-visible, -.explorer__button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -/* The verdict sits beside the button, or under it when the panel is narrow. */ -.explorer__draw { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 0.5rem 0.6rem; -} - -.explorer__button { - flex: none; - padding: 0.3rem 0.7rem; - border: 1px solid var(--accent); - border-radius: var(--r-xs); - background: var(--accent); - color: var(--paper-2); - font-family: var(--mono); - font-size: var(--fs-label); - font-weight: 500; - letter-spacing: 0.08em; - /* The font's own line height sets the button's height. */ - line-height: normal; - text-transform: uppercase; - transition: - background-color var(--fast) var(--ease), - border-color var(--fast) var(--ease); -} - -.explorer__button:hover { - border-color: var(--accent-deep); - background: var(--accent-deep); -} - -.explorer__verdict { - flex: 1 1 13rem; - min-height: 2.9em; - padding-left: 0.55rem; - border-left: 2px solid var(--hair); - color: var(--ink-2); - font-size: 0.75rem; - line-height: 1.45; -} - -.explorer__verdict--fires { - border-left-color: var(--accent); - color: var(--ink); -} - -.explorer__verdict--waits { - border-left-color: var(--ink-2); - color: var(--ink); -} - -.explorer__rate { - padding-top: 0.45rem; - border-top: 1px solid var(--hair-2); - color: var(--ink-2); - font-family: var(--mono); - font-size: var(--fs-xs); -} - -.explorer__value { - color: var(--ink); - font-weight: 600; -} - -/* The plot draws at one unit per pixel, so its text sizes are pixels on screen. */ -.survival-plot svg { - display: block; - overflow: visible; -} - -.survival-plot__text, -.survival-plot__draw-label { - font-family: var(--mono); - font-size: var(--fs-svg); -} - -.survival-plot__text--muted { - fill: var(--ink-2); -} - -.survival-plot__text--accent { - fill: var(--accent); -} - -.survival-plot__band { - fill: var(--accent-wash); -} - -.survival-plot__mark { - stroke: var(--accent); - stroke-width: 3; -} - -.survival-plot__axis { - stroke: var(--ink-2); - stroke-width: 1.25; -} - -.survival-plot__guide { - stroke: var(--accent); - stroke-width: 1; - stroke-dasharray: 3 3; -} - -.survival-plot__curve { - fill: none; - stroke: var(--ink); - stroke-width: 1.5; - stroke-linejoin: round; -} - -.survival-plot__draw-line { - stroke-width: 1.25; -} - -.survival-plot__draw-label { - font-weight: 600; -} - -.survival-plot__draw--fires .survival-plot__draw-line { - stroke: var(--accent); -} - -.survival-plot__draw--fires .survival-plot__draw-dot, -.survival-plot__draw--fires .survival-plot__draw-label { - fill: var(--accent); -} - -.survival-plot__draw--waits .survival-plot__draw-line { - stroke: var(--ink-2); - stroke-dasharray: 2 2; -} - -.survival-plot__draw--waits .survival-plot__draw-dot, -.survival-plot__draw--waits .survival-plot__draw-label { - fill: var(--ink-2); -} diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx deleted file mode 100644 index 4e5722f9..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useState } from "react"; - -import { delayFromDraw, effectiveRate, fireModifier, firesAt, threshold } from "./coin-clock-explorer/exponential"; -import { fixed } from "./coin-clock-explorer/format"; -import { modifierClass } from "./coin-clock-explorer/modifier-class"; -import { RangeField } from "./coin-clock-explorer/range-field"; -import { SurvivalPlot } from "./coin-clock-explorer/survival-plot"; - -import "./coin-clock-explorer.css"; - -/** Birth's rate. */ -const LAMBDA = 2; - -/** A uniform draw in (0, 1], so that its delay -ln(u)/λ is always finite. */ -function uniformDraw(): number { - return 1 - Math.random(); -} - -/** The parts of an equation joined by no-break spaces, so that a line never breaks inside it. */ -function unbroken(...parts: string[]): string { - return parts.join(" "); -} - -/** What one draw decides, read by the coin and by the clock it stands for. */ -function verdictOf(dt: number, draw: number | undefined): string { - if (draw === undefined) { - return "Press Draw to toss Birth's coin for one step."; - } - const theta = threshold(LAMBDA, dt); - const u = fixed(draw, 3); - const th = fixed(theta, 3); - const tau = fixed(delayFromDraw(LAMBDA, draw), 2); - return firesAt(draw, theta) - ? `${unbroken("u", "=", u, "≥", "θ", "=", th)}: fires. Its clock would expire at ${unbroken("τ", "=", tau, "≤", "Δt")}.` - : `${unbroken("u", "=", u, "<", "θ", "=", th)}: waits. Its clock would expire at ${unbroken("τ", "=", tau, ">", "Δt")}.`; -} - -/** - * Birth's coin against its clock: one draw read both ways on the survival - * curve, and the rate the coin reaches when it fires at most once per step. - */ -export const CoinClockExplorer: React.FC = () => { - const [dt, setDt] = useState(0.5); - const [draw, setDraw] = useState(undefined); - const theta = threshold(LAMBDA, dt); - - return ( -
    - - -
    - - {verdictOf(dt, draw)} -
    -
    - Coin rate p/Δt = {fixed(effectiveRate(LAMBDA, dt), 2)}, below λ = {LAMBDA} -
    -
    - ); -}; diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.test.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.test.ts deleted file mode 100644 index 7548e331..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - delayFromDraw, - effectiveRate, - fireModifier, - fireProbability, - firesAt, - survival, - threshold, -} from "./exponential"; -import { fixed } from "./format"; - -const RATES = [0.2, 0.5, 1, 2, 2.5, 4]; -const STEPS = [0.05, 0.25, 0.5, 1, 2]; - -describe("survival", () => { - it("starts at one and decays as e^(-λt)", () => { - // GIVEN rates 1 and 2, at times 0, 0.5 and 1 - // THEN nothing has expired at 0, and only λt matters - expect(survival(2, 0)).toBe(1); - expect(survival(1, 1)).toBeCloseTo(Math.exp(-1), 12); - expect(survival(2, 0.5)).toBeCloseTo(survival(1, 1), 12); - }); -}); - -describe("threshold and fire probability", () => { - it("read the queue's Arrive at rate 2.5 and dt 0.5 as the compiler does", () => { - // GIVEN Arrive's rate and the queue's time step - // THEN the threshold is the one in the Python, and the chance is its complement - expect(threshold(2.5, 0.5)).toBeCloseTo(0.2865047968601901, 15); - expect(fireProbability(2.5, 0.5)).toBeCloseTo(0.7134952031398099, 15); - }); - - it("split the unit interval between waiting and firing", () => { - // GIVEN every rate and step - for (const lambda of RATES) { - for (const dt of STEPS) { - // THEN θ and p add up to one - expect(threshold(lambda, dt) + fireProbability(lambda, dt)).toBeCloseTo(1, 14); - } - } - }); -}); - -describe("delayFromDraw", () => { - it("inverts the survival: u = S(τ)", () => { - // GIVEN draws across (0, 1] - for (const lambda of RATES) { - for (const u of [0.01, 0.2, 0.5, 0.9, 1]) { - // WHEN a draw is read as a delay - const tau = delayFromDraw(lambda, u); - // THEN the survival at that delay is the draw - expect(survival(lambda, tau)).toBeCloseTo(u, 12); - } - } - }); - - it("averages to the mean delay 1/λ over the draws", () => { - // GIVEN draws spread evenly over (0, 1] - const n = 100_000; - // WHEN each is read as a delay at rate 2 - let sum = 0; - for (let i = 0; i < n; i++) { - sum += delayFromDraw(2, (i + 0.5) / n); - } - // THEN the delays average to 1/2 - expect(sum / n).toBeCloseTo(0.5, 3); - }); -}); - -describe("coin and clock", () => { - it("decide the same for every draw: u ≥ θ exactly when τ ≤ dt", () => { - // GIVEN every rate, step and a fine grid of draws - const draws = Array.from({ length: 2000 }, (_, i) => (i + 1) / 2000); - // WHEN each draw is read both as a coin and as a clock - const disagreements = RATES.flatMap((lambda) => - STEPS.flatMap((dt) => - draws - .filter((u) => (delayFromDraw(lambda, u) <= dt) !== firesAt(u, threshold(lambda, dt))) - .map((u) => ({ lambda, dt, u })), - ), - ); - // THEN the clock expires within the step exactly when the coin fires - expect(disagreements).toEqual([]); - }); -}); - -describe("effectiveRate", () => { - it("stays below λ for every step", () => { - // GIVEN every rate and step - for (const lambda of RATES) { - for (const dt of STEPS) { - // THEN a coin that fires at most once per step falls short of λ - expect(effectiveRate(lambda, dt)).toBeLessThan(lambda); - } - } - }); - - it("rises towards λ as the step shrinks", () => { - // GIVEN steps that shrink by orders of magnitude - // WHEN the coin's rate is read at each - const rates = [1, 0.5, 0.1, 0.01, 0.0001].map((dt) => effectiveRate(2, dt)); - // THEN it rises at every step and ends within 0.001 of λ = 2 - for (let i = 1; i < rates.length; i++) { - expect(rates[i]).toBeGreaterThan(rates[i - 1] ?? 0); - } - expect(rates.at(-1)).toBeCloseTo(2, 3); - }); - - it("prints below λ = 2 with two decimals over the Δt slider", () => { - // GIVEN every step the slider offers, 0.05 to 2 by 0.05 - for (let d = 1; d <= 40; d++) { - // WHEN the readout prints the coin's rate at Birth's λ = 2 - const printed = Number(fixed(effectiveRate(2, d / 20), 2)); - // THEN it reads below λ - expect(printed).toBeLessThan(2); - } - }); -}); - -describe("firesAt", () => { - it("fires on the threshold and above, waits below", () => { - // GIVEN a threshold of 0.5, and draws on it, above it and below it - // THEN the test is u ≥ θ - expect(firesAt(0.5, 0.5)).toBe(true); - expect(firesAt(0.6, 0.5)).toBe(true); - expect(firesAt(0.4, 0.5)).toBe(false); - }); -}); - -describe("fireModifier", () => { - it("names the reading of a draw, and nothing without one", () => { - // GIVEN a threshold of 0.5, and no draw, a draw above it and one below it - // THEN a draw reads as fires or waits, and no draw as nothing - expect(fireModifier(undefined, 0.5)).toBeUndefined(); - expect(fireModifier(0.7, 0.5)).toBe("fires"); - expect(fireModifier(0.2, 0.5)).toBe("waits"); - }); -}); diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.ts deleted file mode 100644 index 12b72f9b..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/exponential.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * The exponential delay behind both ways of compiling a rate: the survival a - * clock reads, the threshold a coin tests, and the identities between them. - */ - -/** The probability that a delay of rate `lambda` has not expired by time `t`: e^(-λt). */ -export function survival(lambda: number, t: number): number { - return Math.exp(-lambda * t); -} - -/** The coin's threshold for one step of `dt`, computed at compile time: θ = e^(-λ·dt). */ -export function threshold(lambda: number, dt: number): number { - return survival(lambda, dt); -} - -/** The chance a coin fires in one step of `dt`: p = 1 - e^(-λ·dt). */ -export function fireProbability(lambda: number, dt: number): number { - return -Math.expm1(-lambda * dt); -} - -/** The clock delay one uniform draw `u` in (0, 1] stands for: τ = -ln(u)/λ, so that u = S(τ). */ -export function delayFromDraw(lambda: number, u: number): number { - return -Math.log(u) / lambda; -} - -/** Whether a draw `u` reaches the threshold `theta`: the coin's test, u ≥ θ. */ -export function firesAt(u: number, theta: number): boolean { - return u >= theta; -} - -/** The class modifier for a draw against `theta`, or undefined when there is no draw. */ -export function fireModifier(u: number | undefined, theta: number): "fires" | "waits" | undefined { - if (u === undefined) { - return undefined; - } - return firesAt(u, theta) ? "fires" : "waits"; -} - -/** The rate a coin achieves when it fires at most once per step: p/dt, always below λ. */ -export function effectiveRate(lambda: number, dt: number): number { - return fireProbability(lambda, dt) / dt; -} diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/format.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/format.ts deleted file mode 100644 index 3a790fec..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/format.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** A number for a readout, with a fixed count of decimals and a true minus sign. */ -export function fixed(value: number, digits: number): string { - return Number.isFinite(value) ? value.toFixed(digits).replace("-", "−") : "∞"; -} diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.test.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.test.ts deleted file mode 100644 index 6a6da0c1..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { modifierClass } from "./modifier-class"; - -describe("modifierClass", () => { - it("adds the modifier when there is one", () => { - // GIVEN the block explorer__draw, with the modifier fires and with none - // THEN a modifier is appended to the block, and no modifier leaves the block alone - expect(modifierClass("explorer__draw", "fires")).toBe("explorer__draw explorer__draw--fires"); - expect(modifierClass("explorer__draw", undefined)).toBe("explorer__draw"); - }); -}); diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.ts deleted file mode 100644 index 6b207d81..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/modifier-class.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** A BEM class with its modifier when there is one: "block block--modifier", or "block". */ -export function modifierClass(block: string, modifier: string | undefined): string { - return modifier === undefined ? block : `${block} ${block}--${modifier}`; -} diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.test.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.test.ts deleted file mode 100644 index 2f37dbc8..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { curvePath, niceCeil, xOf, yOf } from "./plot-frame"; - -import type { PlotFrame } from "./plot-frame"; - -const FRAME: PlotFrame = { width: 120, height: 120, left: 10, right: 10, top: 10, bottom: 10, horizon: 4 }; - -describe("niceCeil", () => { - it("rounds up to a round number", () => { - // GIVEN round numbers, numbers between them, and zero - // THEN a round number stays, and anything else goes up to the next one - expect(niceCeil(3)).toBe(3); - expect(niceCeil(3.1)).toBe(4); - expect(niceCeil(0.75)).toBe(0.8); - expect(niceCeil(12.5)).toBe(15); - expect(niceCeil(0)).toBe(1); - }); -}); - -describe("xOf and yOf", () => { - it("map time across and probability up, clamped to the frame", () => { - // GIVEN a frame from 10 to 110 both ways, with a horizon of 4 - // THEN t = 2 sits halfway across, t = 9 at the edge, and probability 1 at the top - expect(xOf(FRAME, 0)).toBe(10); - expect(xOf(FRAME, 2)).toBe(60); - expect(xOf(FRAME, 9)).toBe(110); - expect(yOf(FRAME, 1)).toBe(10); - expect(yOf(FRAME, 0)).toBe(110); - }); -}); - -describe("curvePath", () => { - it("starts at the first sample and stops at the horizon", () => { - // GIVEN a frame with a horizon of 4 - // WHEN a flat curve is drawn from 0 to 10 in four samples - const path = curvePath(FRAME, () => 1, 0, 10, 4); - // THEN it runs along the top up to the horizon, 4 - expect(path).toBe("M10,10L35,10L60,10L85,10L110,10"); - }); - - it("is empty when the span starts past the horizon", () => { - // GIVEN a frame that ends at 4 - // THEN a curve that starts at 5 draws nothing - expect(curvePath(FRAME, () => 1, 5, 10)).toBe(""); - }); -}); diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.ts deleted file mode 100644 index 7df819cf..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/plot-frame.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * The coordinate space of a small time plot: t from 0 to a horizon across, - * a probability from 0 to 1 up, drawn in an SVG viewBox. - */ -export type PlotFrame = { - width: number; - height: number; - left: number; - right: number; - top: number; - bottom: number; - /** The last time on the axis. */ - horizon: number; -}; - -const NICE_STEPS = [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; - -/** The smallest round number at or above `x`: 1, 1.5, 2, 2.5, 3, 4, 5, 6 or 8 times a power of ten. */ -export function niceCeil(x: number): number { - if (!(x > 0) || !Number.isFinite(x)) { - return 1; - } - const power = 10 ** Math.floor(Math.log10(x)); - const step = NICE_STEPS.find((candidate) => candidate * power >= x * (1 - 1e-9)) ?? 10; - return step * power; -} - -/** How far across the plot sits time `t`. */ -export function xOf(frame: PlotFrame, t: number): number { - const span = frame.width - frame.left - frame.right; - return frame.left + (Math.min(Math.max(t, 0), frame.horizon) / frame.horizon) * span; -} - -/** How far down the plot sits probability `v`. */ -export function yOf(frame: PlotFrame, v: number): number { - const span = frame.height - frame.top - frame.bottom; - return frame.top + (1 - Math.min(Math.max(v, 0), 1)) * span; -} - -/** An SVG path through `curve(t)` for t from `from` to `to`, cut at the horizon. */ -export function curvePath( - frame: PlotFrame, - curve: (t: number) => number, - from: number, - to: number, - samples = 96, -): string { - const end = Math.min(to, frame.horizon); - if (end <= from) { - return ""; - } - const points: string[] = []; - for (let i = 0; i <= samples; i++) { - const t = from + ((end - from) * i) / samples; - points.push(`${round(xOf(frame, t))},${round(yOf(frame, curve(t)))}`); - } - return `M${points.join("L")}`; -} - -function round(value: number): number { - return Math.round(value * 10) / 10; -} diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/range-field.tsx b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/range-field.tsx deleted file mode 100644 index ec1653c1..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/range-field.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useId } from "react"; - -type RangeFieldProps = { - /** What the slider sets, with its symbol. */ - label: React.ReactNode; - value: number; - /** The value as read beside the label and by screen readers. */ - valueText: string; - min: number; - max: number; - step: number; - onChange: (value: number) => void; -}; - -/** A labelled range slider with its value read out beside the label. */ -export const RangeField: React.FC = ({ label, value, valueText, min, max, step, onChange }) => { - const id = useId(); - return ( -
    - - onChange(Number(event.currentTarget.value))} - /> -
    - ); -}; diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/survival-plot.tsx b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/survival-plot.tsx deleted file mode 100644 index 8dd59ee3..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/survival-plot.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { delayFromDraw, fireModifier, survival, threshold } from "./exponential"; -import { fixed } from "./format"; -import { modifierClass } from "./modifier-class"; -import { curvePath, niceCeil, xOf, yOf } from "./plot-frame"; -import { useWidth } from "./use-width"; - -import type { PlotFrame } from "./plot-frame"; - -type SurvivalPlotProps = { - lambda: number; - dt: number; - /** The last uniform draw, if any. */ - draw: number | undefined; -}; - -const HEIGHT = 164; - -/** The horizon that shows the step and the delay until only 5% of clocks are left. */ -function survivalHorizon(lambda: number, dt: number): number { - return niceCeil(Math.max(dt * 1.25, 3 / lambda)); -} - -/** Where a label under the axis starts, so that it stays beside the point it names and inside the plot. */ -function anchorNear(x: number, frame: PlotFrame, room: number): "start" | "middle" | "end" { - if (x - frame.left < room) { - return "start"; - } - return frame.width - frame.right - x < room ? "end" : "middle"; -} - -/** - * The survival S(t) = e^(-λt) with the step [0, Δt], the threshold θ = S(Δt) - * and the band [θ, 1] of draws that fire, and the last draw read both ways: - * u on the probability axis, τ = -ln(u)/λ on the time axis, joined through - * the curve. It draws at one unit per pixel of its measured width, so its - * text keeps its size whatever the panel's width. - */ -export const SurvivalPlot: React.FC = ({ lambda, dt, draw }) => { - const [width, measure] = useWidth(); - const frame: PlotFrame = { - width, - height: HEIGHT, - left: 38, - right: 12, - top: 24, - bottom: 36, - horizon: survivalHorizon(lambda, dt), - }; - const theta = threshold(lambda, dt); - const x0 = xOf(frame, 0); - const xEnd = xOf(frame, frame.horizon); - const xStep = xOf(frame, dt); - const yBase = yOf(frame, 0); - const yTop = yOf(frame, 1); - const yTheta = yOf(frame, theta); - const tickRow = yBase + 15; - const delayRow = yBase + 30; - - const tau = draw === undefined ? undefined : delayFromDraw(lambda, draw); - const tauInside = tau !== undefined && tau <= frame.horizon; - const xTau = tau === undefined ? x0 : xOf(frame, tau); - const yDraw = draw === undefined ? yBase : yOf(frame, draw); - - const description = - `Survival S(t) = e^(−${fixed(lambda, 0)}t) up to t = ${frame.horizon}. ` + - `The step ends at Δt = ${fixed(dt, 2)}, where S(Δt) = θ = ${fixed(theta, 3)}; draws above θ fire.` + - (draw === undefined || tau === undefined - ? "" - : ` The last draw u = ${fixed(draw, 3)} meets the curve at τ = ${fixed(tau, 2)}.`); - - return ( -
    - {width === 0 ? null : ( - - - S(t) = e^(−{fixed(lambda, 0)}t) - - - - - {yTheta - yTop > 30 ? ( - - p - - ) : null} - - - - - - - survival(lambda, t), 0, frame.horizon)} /> - - {yTheta - yTop >= 16 ? ( - - 1 - - ) : null} - - {fixed(theta, 2)} - - {yBase - yTheta >= 16 ? ( - - 0 - - ) : null} - - {xStep - x0 >= 22 ? ( - - 0 - - ) : null} - = 22 ? "middle" : "start"} - > - Δt - - {xEnd - xStep >= 60 ? ( - - t = {frame.horizon} - - ) : null} - - {draw === undefined || tau === undefined ? null : ( - - - {tauInside ? : null} - - {tauInside ? : null} - - u - - {tauInside ? ( - - τ = {fixed(tau, 2)} - - ) : ( - - τ = {fixed(tau, 2)} → - - )} - - )} - - )} -
    - ); -}; diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/use-width.ts b/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/use-width.ts deleted file mode 100644 index 4e5ef9e7..00000000 --- a/pocs/zeroth-playground/examples/birth-death-clocked/components/coin-clock-explorer/use-width.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useState } from "react"; - -/** - * The width of an element in CSS pixels, kept up to date by a ResizeObserver, - * so that a plot can draw at one pixel per unit and keep its text at a fixed - * size. It is 0 until the element is laid out. - */ -export function useWidth(): readonly [number, React.RefCallback] { - const [width, setWidth] = useState(0); - function observe(element: HTMLElement | null) { - if (element === null) { - return undefined; - } - const observer = new ResizeObserver(([entry]) => { - if (entry !== undefined) { - setWidth(entry.contentRect.width); - } - }); - observer.observe(element); - return () => observer.disconnect(); - } - return [width, observe]; -} diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts b/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts index 825d0b33..fafd4df9 100644 --- a/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts +++ b/pocs/zeroth-playground/examples/birth-death-clocked/meta.ts @@ -3,9 +3,6 @@ import type { ExampleMeta } from "../catalog"; export default { feature: "Rates as clocks", title: "Birth–death", - summary: "A rate becomes a clock that runs down in continuous time.", - rung: "clocks", order: 60, - listed: true, options: { rates: "clock" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml b/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml index f3af5f69..f4ccc4ce 100644 --- a/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml +++ b/pocs/zeroth-playground/examples/birth-death-clocked/net.pn.yaml @@ -1,5 +1,4 @@ name: birth_death -description: 'Births add to the population at rate 2, and each death removes one member at rate 1 while any remain.' kind: stochastic places: diff --git a/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx b/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx index ed4f0596..55b53e0e 100644 --- a/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx +++ b/pocs/zeroth-playground/examples/birth-death-clocked/page.mdx @@ -1,28 +1,9 @@ -import { CoinClockExplorer } from "./components/coin-clock-explorer"; +The same Birth–death net, in continuous time. Each transition waits for an exponential delay: -Under clocks, each transition owns a clock armed with an exponential delay at its rate. Time flows until a clock reaches 0; the transition fires and the clock re-arms. +$$ +\tau \sim \operatorname{Exp}(\lambda) +$$ -The compiler writes this in Zeroth's SPN theory: a clock `clk_T` and an event `ev_T` per transition, a Nat per place. `Transition_Birth` fires at 0 and re-arms: - -```python -fires_Birth = clk_Birth == 0 -return ite(fires_Birth, exp(2.0), clk_Birth), ite(fires_Birth, ~ev_Birth, None) -``` - -Its `flow` runs the clock down against the time `t`. Past 0 it has no value, which stops time, and the event's flow `0` keeps the event still: - -```python -return ite(clk_Birth >= 0, -1 * d(t), None), 0 -``` - -`Death` needs a token, so its clock runs only while `Population` holds one. `Place_Population` counts the events with `fired`, and `compose` hides the clocks. - -
    - -
    - -### Open questions - - +When its clock reaches zero, it fires and draws another delay. Death's clock pauses while Population is empty. diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css deleted file mode 100644 index 63fddd69..00000000 --- a/pocs/zeroth-playground/examples/birth-death/components/coin-draws.css +++ /dev/null @@ -1,103 +0,0 @@ -/* -- Birth's coin: one draw per step on [0, 1], against the threshold ------ */ - -.coin-draws { - --coin-step: 1.6s; - - font-family: var(--mono); -} - -/* The readout names the step, the draw and what it decides; one line at a time. */ -.coin-draws__readout { - display: grid; - font-size: var(--fs-xs); - line-height: 1.4; -} - -.coin-draws__step, -.coin-draws__summary { - grid-area: 1 / 1; -} - -.coin-draws__step--fires { - color: var(--accent); -} - -.coin-draws__step--waits { - color: var(--ink-2); -} - -.coin-draws__summary { - display: none; - color: var(--ink); -} - -.coin-draws__axis { - display: block; - margin-top: 0.2rem; - overflow: visible; -} - -.coin-draws__region, -.coin-draws__tick { - fill: var(--ink-2); - font-size: var(--fs-svg); -} - -.coin-draws__region--fires, -.coin-draws__tick--threshold { - fill: var(--accent); -} - -.coin-draws__band { - fill: var(--accent-wash); -} - -.coin-draws__line { - stroke: var(--ink-2); - stroke-width: 1.25; -} - -.coin-draws__threshold { - stroke: var(--accent); - stroke-width: 1.5; -} - -.coin-draws__draw--fires { - fill: var(--accent); -} - -.coin-draws__draw--waits { - fill: var(--paper-2); - stroke: var(--ink-2); - stroke-width: 1.5; -} - -/* Each step shows its own draw and readout for a quarter of the loop. */ -.coin-draws__step, -.coin-draws__draw { - animation: coin-draws-show calc(4 * var(--coin-step)) step-end infinite; - animation-delay: calc(var(--step) * var(--coin-step) - 4 * var(--coin-step)); -} - -/* Hidden just before the quarter ends, so that two steps never show together. */ -@keyframes coin-draws-show { - 0% { visibility: visible; } - 24.9% { visibility: hidden; } - 100% { visibility: hidden; } -} - -/* Without motion, the four draws show at once over a count of those that fire. */ -@media (prefers-reduced-motion: reduce) { - .coin-draws__step, - .coin-draws__draw { - animation: none; - } - - .coin-draws__step { - display: none; - } - - .coin-draws__summary { - display: inline; - } -} diff --git a/pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx b/pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx deleted file mode 100644 index cd28c03b..00000000 --- a/pocs/zeroth-playground/examples/birth-death/components/coin-draws.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import "./coin-draws.css"; - -/** Birth's threshold at rate 2 over a step of 0.5: e^(-1). */ -const THRESHOLD = Math.exp(-2 * 0.5); - -/** Four draws of u_Birth, one per step: three reach the threshold, close to the chance 0.63. */ -const DRAWS = [0.1, 0.71, 0.52, 0.88]; - -/** Where a value in [0, 1] sits along the axis, as a share of the drawing's width. */ -function across(value: number): string { - return `${8 + value * 84}%`; -} - -function verdict(u: number): "fires" | "waits" { - return u >= THRESHOLD ? "fires" : "waits"; -} - -/** - * Birth's coin over four steps: each step one draw lands on [0, 1], and the - * transition fires when it reaches the threshold. Without motion, the four - * draws show at once. - */ -export const CoinDraws: React.FC = () => { - const fired = DRAWS.filter((u) => verdict(u) === "fires").length; - return ( - - ); -}; diff --git a/pocs/zeroth-playground/examples/birth-death/meta.ts b/pocs/zeroth-playground/examples/birth-death/meta.ts index 81b55213..9a61ddff 100644 --- a/pocs/zeroth-playground/examples/birth-death/meta.ts +++ b/pocs/zeroth-playground/examples/birth-death/meta.ts @@ -3,9 +3,6 @@ import type { ExampleMeta } from "../catalog"; export default { feature: "Rates as coins", title: "Birth–death", - summary: "A rate becomes a draw tested against a threshold each step.", - rung: "steps", order: 50, - listed: true, options: { shape: "modular", dt: 0.5 }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/birth-death/net.pn.yaml b/pocs/zeroth-playground/examples/birth-death/net.pn.yaml index f3af5f69..f4ccc4ce 100644 --- a/pocs/zeroth-playground/examples/birth-death/net.pn.yaml +++ b/pocs/zeroth-playground/examples/birth-death/net.pn.yaml @@ -1,5 +1,4 @@ name: birth_death -description: 'Births add to the population at rate 2, and each death removes one member at rate 1 while any remain.' kind: stochastic places: diff --git a/pocs/zeroth-playground/examples/birth-death/page.mdx b/pocs/zeroth-playground/examples/birth-death/page.mdx index 422c3410..cd3f289e 100644 --- a/pocs/zeroth-playground/examples/birth-death/page.mdx +++ b/pocs/zeroth-playground/examples/birth-death/page.mdx @@ -1,20 +1,11 @@ -import { CoinDraws } from "./components/coin-draws"; +Birth adds a token. Death removes one when Population is nonempty. -A transition at rate $\lambda$ fires within a step of length $\Delta t$ with probability $1 - e^{-\lambda \Delta t}$. The compiler makes that a coin: a draw `u_T` each step, tested against a threshold fixed at compile time. +Each enabled transition gets one draw per step. At rate $\lambda$ over a step $\Delta t$, its firing probability is: -`Birth` at rate 2, over a step of 0.5, fires when its draw reaches $e^{-1} \approx 0.37$, so in about 63% of steps: +$$ +p = 1 - e^{-\lambda\Delta t} +$$ -```python -return X(u_Birth) >= 0.36787944117144233 -``` +For Birth, $\lambda=2$ and $\Delta t=0.5$, so $p\approx0.63$. -
    - -
    - -`Death` also needs a token, so it tests `Population >= 1.0` beside its draw. The draws are Reals, so the places are Reals too, in the theory LRA. - -### Options - -- **Time step** sets $\Delta t$, and every threshold follows. -- **Marking** on Int keeps the places Int and moves each draw into an LRA module, `Draw_Birth`, that writes the Bool the guard reads. +Change **Time step** to see the threshold change in Python. Each transition can fire at most once per step. diff --git a/pocs/zeroth-playground/examples/bucket/meta.ts b/pocs/zeroth-playground/examples/bucket/meta.ts deleted file mode 100644 index 8f1ee515..00000000 --- a/pocs/zeroth-playground/examples/bucket/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "Code strings are refused", - title: "Bucket", - summary: "A rate written as code waits for a code parser.", - rung: "colours", - order: 90, - listed: false, - options: {}, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/bucket/net.pn.yaml b/pocs/zeroth-playground/examples/bucket/net.pn.yaml deleted file mode 100644 index 296b4c33..00000000 --- a/pocs/zeroth-playground/examples/bucket/net.pn.yaml +++ /dev/null @@ -1,26 +0,0 @@ -name: bucket -kind: stochastic - -colours: - Ball: - x: real - -places: - Pool: - colour: Ball - capacity: 2 - Done: - -transitions: - Take: - inputs: - Pool: - outputs: - Done: - rate: | - return 0.6 * (input.Pool[0].x / 100); - -marking: - Pool: - - {x: 2} - - {x: 0.5} diff --git a/pocs/zeroth-playground/examples/bucket/page.mdx b/pocs/zeroth-playground/examples/bucket/page.mdx deleted file mode 100644 index fc8f8c50..00000000 --- a/pocs/zeroth-playground/examples/bucket/page.mdx +++ /dev/null @@ -1,13 +0,0 @@ -`Take` fires at a rate that reads a token's attribute, so the IR writes the rate as code. The compiler reads code only through a parser it is given, and this playground passes none. - -```ts -return 0.6 * (input.Pool[0].x / 100); -``` - -### Refused here - -The net is refused with `code-not-parsed`; a TypeScript parser does not fit one HTML file. With a parser, each position of `Pool` gets a present flag and an `x`, `Take` tries the tokens in order, and the survivors close up at the end of the step. **Shape** on modular refuses the net earlier, with `modular-coloured-not-lowered`. - -### Open questions - - diff --git a/pocs/zeroth-playground/examples/cafe-queue/meta.ts b/pocs/zeroth-playground/examples/cafe-queue/meta.ts index d1d753c4..7fe47631 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/meta.ts +++ b/pocs/zeroth-playground/examples/cafe-queue/meta.ts @@ -1,11 +1,8 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Two inputs under clocks", - title: "Café queue", - summary: "A clock that runs only while both input places hold a token.", - rung: "clocks", + feature: "Two inputs", + title: "Service", order: 70, - listed: true, options: { rates: "clock" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml b/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml index 499e01a3..dea74f94 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml +++ b/pocs/zeroth-playground/examples/cafe-queue/net.pn.yaml @@ -1,33 +1,20 @@ name: cafe_queue -description: 'Customers arrive, wait for a free member of staff, are served and leave.' kind: stochastic places: Waiting: FreeStaff: Serving: - Served: transitions: - Arrive: - outputs: - Waiting: - rate: 1.2 BeginService: inputs: Waiting: FreeStaff: outputs: Serving: - rate: 0.4 - FinishService: - inputs: - Serving: - outputs: - Served: - FreeStaff: - rate: 0.5 + rate: 1 marking: - Waiting: 3 - FreeStaff: 2 + Waiting: 1 + FreeStaff: 0 diff --git a/pocs/zeroth-playground/examples/cafe-queue/page.mdx b/pocs/zeroth-playground/examples/cafe-queue/page.mdx index cc94d1a1..aae4515f 100644 --- a/pocs/zeroth-playground/examples/cafe-queue/page.mdx +++ b/pocs/zeroth-playground/examples/cafe-queue/page.mdx @@ -1,15 +1,9 @@ -`BeginService` needs a customer in `Waiting` and a free member of staff in `FreeStaff`. Under clocks, what does an empty input place do to its clock? - -Here the transition decides: its clock runs only while both places hold a token, and it fires only then. +BeginService needs a waiting customer and a free member of staff. It takes one token from each and adds one to Serving. ```python fires_BeginService = (clk_BeginService == 0) & (Waiting != 0) & (FreeStaff != 0) ``` -Zeroth's own `birth_death.py` lets the places decide: the clock always runs, and at expiry each input place gives a token if it has one. With one input the two rules agree, since an exponential delay has no memory. With two they do not: `BeginService` could take a customer while `FreeStaff` is empty. - -### Open questions +FreeStaff starts empty, so the clock pauses. Set `FreeStaff: 1` to enable it. - - diff --git a/pocs/zeroth-playground/examples/capacity/meta.ts b/pocs/zeroth-playground/examples/capacity/meta.ts deleted file mode 100644 index 1efb4f97..00000000 --- a/pocs/zeroth-playground/examples/capacity/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "A capped place", - title: "Capacity", - summary: "A later producer counts the token an earlier one adds in the same step.", - rung: "steps", - order: 30, - listed: true, - options: { shape: "modular" }, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/capacity/net.pn.yaml b/pocs/zeroth-playground/examples/capacity/net.pn.yaml deleted file mode 100644 index ac4e0c7f..00000000 --- a/pocs/zeroth-playground/examples/capacity/net.pn.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: capacity -kind: plain - -places: - LeftSource: - RightSource: - Buffer: - capacity: 2 - Sink: - -transitions: - PutLeft: - inputs: - LeftSource: - outputs: - Buffer: - PutRight: - inputs: - RightSource: - outputs: - Buffer: - Take: - inputs: - Buffer: - outputs: - Sink: - -marking: - LeftSource: 5 - RightSource: 5 diff --git a/pocs/zeroth-playground/examples/capacity/page.mdx b/pocs/zeroth-playground/examples/capacity/page.mdx deleted file mode 100644 index 7a4fc649..00000000 --- a/pocs/zeroth-playground/examples/capacity/page.mdx +++ /dev/null @@ -1,21 +0,0 @@ -`Buffer` holds two tokens at most, and `PutLeft` and `PutRight` can both fill it in one step. The second producer must count a token that lands only at the end of the step. - -`PutRight` tests `fill_Buffer`, what `Buffer` would hold if the step ended now: - -```python -fill_Buffer = Buffer -fill_Buffer = ite(X(fire_PutLeft), fill_Buffer + 1, fill_Buffer) -return (RightSource >= 1) & (fill_Buffer + 1 <= 2) -``` - -`Take` comes after both producers, so the room it frees counts from the next step: a full `Buffer` blocks both, even in a step where `Take` drains it. Clocks refuse a capacity, since SPN tests a count against zero only. - -### Options - -- **Shape** on monolithic keeps one `fill_Buffer` local, updated by every firing that moves `Buffer`. - -### Open questions - - - - diff --git a/pocs/zeroth-playground/examples/catalog.test.ts b/pocs/zeroth-playground/examples/catalog.test.ts index 81d4f60f..9e1d0949 100644 --- a/pocs/zeroth-playground/examples/catalog.test.ts +++ b/pocs/zeroth-playground/examples/catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { EXAMPLES, LADDER, folderOf } from "./catalog"; +import { EXAMPLES, folderOf } from "./catalog"; /** The folders a glob matched, sorted. */ function foldersOf(matches: Record): string[] { @@ -17,13 +17,9 @@ describe("the example folders", () => { expect(EXAMPLES.map((example) => example.id).toSorted()).toEqual(metas); }); - it("climb the ladder rung by rung, each at its own place", () => { - // GIVEN the examples in their order - const rungs = LADDER.map((rung) => rung.id); - // WHEN each is placed on its rung - const placed = EXAMPLES.map((example) => rungs.indexOf(example.rung)); - // THEN the rungs never go down, and no two examples share an order - expect(placed).toEqual(placed.toSorted((a, b) => a - b)); + it("give each example its own order", () => { + // GIVEN the examples in presentation order + // THEN each order occurs once expect(new Set(EXAMPLES.map((example) => example.order)).size).toBe(EXAMPLES.length); }); }); diff --git a/pocs/zeroth-playground/examples/catalog.ts b/pocs/zeroth-playground/examples/catalog.ts index b81fd7fe..45b409a9 100644 --- a/pocs/zeroth-playground/examples/catalog.ts +++ b/pocs/zeroth-playground/examples/catalog.ts @@ -1,57 +1,17 @@ import type { CompilerOptions } from "../compiler"; -/** A rung of the ladder: the examples it holds tackle features of one kind. */ -export type Rung = { - id: "steps" | "clocks" | "colours"; - /** The rung's heading in the picker. */ - title: string; -}; - -/** The ladder, from the plainest net up; examples/README.md says what each rung holds. */ -export const LADDER: readonly Rung[] = [ - { id: "steps", title: "Steps" }, - { id: "clocks", title: "Clocks" }, - { id: "colours", title: "Coloured tokens" }, -]; - -/** What an example's `meta.ts` declares. */ export type ExampleMeta = { - /** What the example tackles: its name in the picker and the heading of its page. */ feature: string; - /** The net it uses, named above the page's heading. */ title: string; - /** One line on what the example shows. */ - summary: string; - rung: Rung["id"]; - /** Where it sits up the ladder: examples sort by it, lowest first. */ order: number; - /** - * Shown in the picker. An unlisted example still opens from its URL hash, - * and shows in the picker while it is the one open. - */ - listed: boolean; - /** - * The compiler options the example opens with: those that show its - * feature. The panel edits them from there. - */ options: CompilerOptions; }; -/** - * An example as the app reads it: its folder's metadata, the folder's name - * as its id (the picker's key, the page's key, the URL hash), and its IR. - */ -export type Example = ExampleMeta & { - id: string; - /** The IR document; the options sit beside it, in `options`. */ - ir: string; -}; +export type Example = ExampleMeta & { id: string; ir: string }; -// One folder per example: `meta.ts`, `net.pn.yaml` and `page.mdx`. const METAS = import.meta.glob("./*/meta.ts", { eager: true, import: "default" }); const NETS = import.meta.glob("./*/net.pn.yaml", { eager: true, query: "?raw", import: "default" }); -/** The folder a globbed path sits in: `./cycle/meta.ts` is `cycle`. */ export function folderOf(path: string): string { return path.split("/").at(-2) ?? path; } @@ -59,13 +19,10 @@ export function folderOf(path: string): string { function exampleAt(path: string, meta: ExampleMeta): Example { const id = folderOf(path); const ir = NETS[`./${id}/net.pn.yaml`]; - if (ir === undefined) { - throw new Error(`examples/${id} has a meta.ts but no net.pn.yaml`); - } + if (ir === undefined) throw new Error(`examples/${id} has no net.pn.yaml`); return { ...meta, id, ir }; } -/** The examples up the ladder, in their `order`. */ export const EXAMPLES: readonly Example[] = Object.entries(METAS) .map(([path, meta]) => exampleAt(path, meta)) .toSorted((a, b) => a.order - b.order); diff --git a/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css b/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css deleted file mode 100644 index 8a1b0195..00000000 --- a/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.css +++ /dev/null @@ -1,209 +0,0 @@ -/* -- Two clocks race for Pool: bars run down to 0, the first to reach it fires -- - - One loop of 6.6 s, each bar's length its clock's time left, out of 3 time - units. Both start armed: TakeLeft with 2.4, TakeRight with 1.0. - 1.0 s TakeRight reaches 0: it takes a token and re-arms with 2.1. - 2.4 s TakeLeft reaches 0: it takes a token and re-arms with 2.7. - 3.1 s TakeRight reaches 0 and takes the last token, re-arming with 1.5. - Pool is empty, so both clocks wait until the loop starts again. */ - -.clock-race { - --race-loop: 6.6s; - - display: flex; - flex-direction: column; - gap: 0.55rem; - font-family: var(--mono); - font-size: var(--fs-xs); - line-height: 1.3; -} - -.clock-race__head { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 0.2rem; -} - -.clock-race__name { - color: var(--ink); - font-weight: 500; -} - -.clock-race__fires { - color: var(--accent); - font-size: 0.66rem; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; - opacity: 0; -} - -.clock-race__track { - display: flex; - align-items: center; - gap: 0.4rem; -} - -.clock-race__zero { - color: var(--ink-2); -} - -.clock-race__rail { - flex: 1; - height: 12px; - border: 1px solid var(--hair); - background: var(--paper); -} - -/* The bar shrinks toward the 0 at its left, as its clock runs down. */ -.clock-race__bar { - display: block; - height: 100%; - background: var(--accent); - transform-origin: left center; - animation: var(--race-loop) linear infinite; -} - -.clock-race__clock--TakeLeft .clock-race__bar { - animation-name: clock-race-left; -} - -.clock-race__clock--TakeRight .clock-race__bar { - animation-name: clock-race-right; -} - -.clock-race__clock--TakeLeft .clock-race__fires { - animation: clock-race-left-fires var(--race-loop) step-end infinite; -} - -.clock-race__clock--TakeRight .clock-race__fires { - animation: clock-race-right-fires var(--race-loop) step-end infinite; -} - -@keyframes clock-race-left { - 0% { transform: scaleX(0.8); } - 36.36% { transform: scaleX(0); } - 36.4% { transform: scaleX(0.9); } - 46.97% { transform: scaleX(0.667); } - 100% { transform: scaleX(0.667); } -} - -@keyframes clock-race-right { - 0% { transform: scaleX(0.333); } - 15.15% { transform: scaleX(0); } - 15.19% { transform: scaleX(0.7); } - 46.97% { transform: scaleX(0); } - 47.01% { transform: scaleX(0.5); } - 100% { transform: scaleX(0.5); } -} - -@keyframes clock-race-left-fires { - 0% { opacity: 0; } - 36.36% { opacity: 1; } - 45% { opacity: 0; } -} - -@keyframes clock-race-right-fires { - 0% { opacity: 0; } - 15.15% { opacity: 1; } - 25% { opacity: 0; } - 46.97% { opacity: 1; } - 57% { opacity: 0; } -} - -.clock-race__pool { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0.15rem; -} - -/* The tokens and the note share one place: the note shows once the last token is taken. */ -.clock-race__contents { - display: grid; - align-items: center; -} - -.clock-race__tokens, -.clock-race__wait { - grid-area: 1 / 1; -} - -.clock-race__tokens { - display: flex; - gap: 0.35rem; -} - -.clock-race__token { - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--accent); - animation: var(--race-loop) step-end infinite; -} - -.clock-race__token--3 { - animation-name: clock-race-take-first; -} - -.clock-race__token--2 { - animation-name: clock-race-take-second; -} - -.clock-race__token--1 { - animation-name: clock-race-take-third; -} - -.clock-race__wait { - color: var(--ink-2); - visibility: hidden; - animation: clock-race-wait var(--race-loop) step-end infinite; -} - -@keyframes clock-race-take-first { - 0% { visibility: visible; } - 15.15% { visibility: hidden; } - 100% { visibility: hidden; } -} - -@keyframes clock-race-take-second { - 0% { visibility: visible; } - 36.36% { visibility: hidden; } - 100% { visibility: hidden; } -} - -@keyframes clock-race-take-third { - 0% { visibility: visible; } - 46.97% { visibility: hidden; } - 100% { visibility: hidden; } -} - -@keyframes clock-race-wait { - 0% { visibility: hidden; } - 46.97% { visibility: visible; } - 100% { visibility: visible; } -} - -/* Without motion: the moment TakeRight reaches 0 first and takes a token, while TakeLeft keeps its time. */ -@media (prefers-reduced-motion: reduce) { - .clock-race :is(.clock-race__bar, .clock-race__fires, .clock-race__token, .clock-race__wait) { - animation: none; - } - - .clock-race__clock--TakeLeft .clock-race__bar { - transform: scaleX(0.467); - } - - .clock-race__clock--TakeRight .clock-race__bar { - transform: scaleX(0); - } - - .clock-race__clock--TakeRight .clock-race__fires { - opacity: 1; - } - - .clock-race__token--3 { - visibility: hidden; - } -} diff --git a/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.tsx b/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.tsx deleted file mode 100644 index 6c875e18..00000000 --- a/pocs/zeroth-playground/examples/conflict-clocked/components/clock-race.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import "./clock-race.css"; - -/** The racers, in the order they are drawn: each bar's animation is in the CSS, keyed by its id. */ -const CLOCKS = ["TakeLeft", "TakeRight"] as const; - -/** Pool's tokens, each taken at the firing its modifier names in the CSS. */ -const TOKENS = 3; - -/** - * TakeLeft and TakeRight race for Pool's tokens. Both clocks run down at the - * same speed toward 0; the first to reach 0 fires, takes a token and re-arms, - * while the other keeps its time. With Pool empty, both clocks wait. - */ -export const ClockRace: React.FC = () => { - return ( - - ); -}; diff --git a/pocs/zeroth-playground/examples/conflict-clocked/meta.ts b/pocs/zeroth-playground/examples/conflict-clocked/meta.ts deleted file mode 100644 index becb5950..00000000 --- a/pocs/zeroth-playground/examples/conflict-clocked/meta.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ExampleMeta } from "../catalog"; - -export default { - feature: "Conflicts under clocks", - title: "Conflict with rates", - summary: "A conflict as a race between clocks, with picks read at expiry.", - rung: "clocks", - order: 80, - listed: true, - options: { rates: "clock", conflicts: "nondet" }, -} satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml b/pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml deleted file mode 100644 index 57a99ab9..00000000 --- a/pocs/zeroth-playground/examples/conflict-clocked/net.pn.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: conflict -kind: stochastic - -places: - Pool: - Left: - Right: - -transitions: - TakeLeft: - inputs: - Pool: - outputs: - Left: - rate: 1 - TakeRight: - inputs: - Pool: - outputs: - Right: - rate: 2 - Return: - inputs: - Left: - outputs: - Pool: - rate: 0.5 - -marking: - Pool: 3 diff --git a/pocs/zeroth-playground/examples/conflict-clocked/page.mdx b/pocs/zeroth-playground/examples/conflict-clocked/page.mdx deleted file mode 100644 index 16fe26f4..00000000 --- a/pocs/zeroth-playground/examples/conflict-clocked/page.mdx +++ /dev/null @@ -1,27 +0,0 @@ -import { ClockRace } from "./components/clock-race"; - -Under clocks a conflict is a race: `TakeLeft` and `TakeRight` both take from `Pool`, and the clock that reaches 0 first takes the token. - -
    - -
    - -With **Conflicts** on nondet, a taker also needs its pick at expiry. A false pick stops time until it turns true. - -```python -fires_TakeLeft = (clk_TakeLeft == 0) & (Pool != 0) & pick_TakeLeft -``` - -`Place_Pool` applies one firing at a time, each case needing the other events off. So two takers that expire together both produce, while `Pool` loses no token. - -### Options - -- **Conflicts** on sweep drops the picks: the first clock to expire fires. - -### Open questions - - - - - - diff --git a/pocs/zeroth-playground/examples/conflict/meta.ts b/pocs/zeroth-playground/examples/conflict/meta.ts index 4d8e4dea..24e67bf9 100644 --- a/pocs/zeroth-playground/examples/conflict/meta.ts +++ b/pocs/zeroth-playground/examples/conflict/meta.ts @@ -1,11 +1,8 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "Who gets the token", + feature: "One shared token", title: "Conflict", - summary: "Record order settles a shared input place, or a pick per contender leaves it open.", - rung: "steps", order: 20, - listed: true, - options: { shape: "modular", conflicts: "nondet" }, + options: { shape: "modular" }, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/conflict/net.pn.yaml b/pocs/zeroth-playground/examples/conflict/net.pn.yaml index 5ac9dde0..908b3345 100644 --- a/pocs/zeroth-playground/examples/conflict/net.pn.yaml +++ b/pocs/zeroth-playground/examples/conflict/net.pn.yaml @@ -12,17 +12,11 @@ transitions: Pool: outputs: Left: - controllable: true TakeRight: inputs: Pool: outputs: Right: - Return: - inputs: - Left: - outputs: - Pool: marking: - Pool: 3 + Pool: 1 diff --git a/pocs/zeroth-playground/examples/conflict/page.mdx b/pocs/zeroth-playground/examples/conflict/page.mdx index c9107c1f..c070c122 100644 --- a/pocs/zeroth-playground/examples/conflict/page.mdx +++ b/pocs/zeroth-playground/examples/conflict/page.mdx @@ -1,19 +1,9 @@ -`TakeLeft` and `TakeRight` both take from `Pool`. When one token is left, which of them gets it? +TakeLeft and TakeRight need the same token. With one token in Pool, at most one can fire. -In record order, `TakeLeft` tests `Pool` and `TakeRight` tests what is left, `avail_Pool`, so `TakeLeft` wins every step. **Conflicts** on nondet leaves the choice open: each contender also waits for a pick, an input that nothing drives. +**In order** gives TakeLeft the token. **External inputs** lets each transition wait for a choice: ```python return (Pool >= 1) & X(pick_TakeLeft) -return (avail_Pool >= 1) & X(pick_TakeRight) ``` -Every way of resolving the conflict is then a run, and all picks true is record order. `Return` is in no conflict and gets no pick. - -### Options - -- **Conflicts** on sweep drops the picks: record order alone decides. -- **Control** on open gives `TakeLeft`, marked `controllable`, an input `go_TakeLeft` of the same kind as a pick. - -### Open questions - - +Both choices may be false, so neither transition has to fire. If both are true, TakeLeft goes first. diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step.css b/pocs/zeroth-playground/examples/cycle/components/one-step.css deleted file mode 100644 index b9e5330b..00000000 --- a/pocs/zeroth-playground/examples/cycle/components/one-step.css +++ /dev/null @@ -1,105 +0,0 @@ -/* -- One step of the cycle: a marking, the firing, the next marking -------- */ - -.one-step { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 0.5rem; - font-family: var(--mono); - font-size: var(--fs-xs); - line-height: 1.3; -} - -.one-step__marking { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.45rem; - padding: 0.5rem 0.4rem 0.55rem; - border: 1px solid var(--hair-2); - border-radius: var(--r-xs); - background: var(--paper-2); -} - -.one-step__label { - color: var(--ink-2); -} - -.one-step__places { - display: flex; - gap: 0.9rem; -} - -.one-step__place { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.3rem; -} - -.one-step__circle { - display: grid; - place-items: center; - width: 26px; - height: 26px; - border: 1.25px solid var(--ink); - border-radius: 50%; - background: var(--paper-2); -} - -.one-step__token { - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--accent); -} - -.one-step__name { - color: var(--ink); - font-weight: 500; -} - -.one-step__firing { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.2rem; - text-align: center; -} - -.one-step__fires { - color: var(--accent); - font-weight: 600; -} - -.one-step__waits { - color: var(--ink-2); -} - -.one-step__arrow::before { - content: "→"; - color: var(--ink-2); - font-size: 1rem; - line-height: 1; -} - -/* Too narrow for three columns: the markings stack, with the firing between them. */ -@container (width < 17rem) { - .one-step { - grid-template-columns: 1fr; - } - - .one-step__firing { - flex-direction: row; - justify-content: center; - gap: 0.6rem; - } - - .one-step__arrow { - order: -1; - } - - .one-step__arrow::before { - content: "↓"; - } -} diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step.tsx b/pocs/zeroth-playground/examples/cycle/components/one-step.tsx deleted file mode 100644 index 26f1083a..00000000 --- a/pocs/zeroth-playground/examples/cycle/components/one-step.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Marking } from "./one-step/marking"; - -import "./one-step.css"; - -/** - * One step of the cycle from A 1, B 0: Go fires and Back waits, so the step - * ends at A 0, B 1. Static, since the step is one update and not a motion. - */ -export const OneStep: React.FC = () => { - return ( - - ); -}; diff --git a/pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx b/pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx deleted file mode 100644 index 2e3ac7d5..00000000 --- a/pocs/zeroth-playground/examples/cycle/components/one-step/marking.tsx +++ /dev/null @@ -1,29 +0,0 @@ -type MarkingProps = { - /** When the marking holds, as read above it. */ - label: string; - /** The tokens in each place, in the order the places are drawn. */ - places: readonly { name: string; tokens: number }[]; -}; - -/** A marking drawn as places side by side, each with a dot per token and its name under it. */ -export const Marking: React.FC = ({ label, places }) => { - return ( -
    - {label} -
    - {places.map(({ name, tokens }) => ( - - - {Array.from({ length: tokens }, (_, index) => ( - - ))} - - - {name} {tokens} - - - ))} -
    -
    - ); -}; diff --git a/pocs/zeroth-playground/examples/cycle/meta.ts b/pocs/zeroth-playground/examples/cycle/meta.ts index 00e29a91..8b9e5837 100644 --- a/pocs/zeroth-playground/examples/cycle/meta.ts +++ b/pocs/zeroth-playground/examples/cycle/meta.ts @@ -1,11 +1,8 @@ import type { ExampleMeta } from "../catalog"; export default { - feature: "One step as a module", + feature: "One step", title: "Cycle", - summary: "A step of a plain net, as one class or as a module per transition and place.", - rung: "steps", order: 10, - listed: true, options: {}, } satisfies ExampleMeta; diff --git a/pocs/zeroth-playground/examples/cycle/page.mdx b/pocs/zeroth-playground/examples/cycle/page.mdx index 76f15ac2..45569ef9 100644 --- a/pocs/zeroth-playground/examples/cycle/page.mdx +++ b/pocs/zeroth-playground/examples/cycle/page.mdx @@ -1,26 +1,9 @@ -import { OneStep } from "./components/one-step"; +Go takes a token from A. It arrives in B at the end of the step, so Back waits until the next step. -One step of a plain net fires every enabled transition once, in record order. A firing takes its tokens at once; the tokens it produces land at the end of the step. +$$ +(A,B)=(1,0) \longrightarrow (0,1) +$$ -
    - -
    +The compiler writes one module for the whole step. Choose **Separate modules** to give each place and transition its own module. -The compiler writes that step as the `next` method of one class, with an Int per place: - -```python -fire_Go = A >= 1 -A = ite(fire_Go, A - 1, A) -fire_Back = B >= 1 -B = ite(fire_Back, B - 1, B) -A = ite(fire_Back, A + 1, A) -B = ite(fire_Go, B + 1, B) -``` - -Four lines test and take in order; the last two land the tokens. - -### Options - -- **Shape** on modular splits the step into one module per transition and one per place. `Place_A` applies `ite(X(fire_Go), A - 1, A)`, where `X(fire_Go)` reads what `Transition_Go` wrote this step. A variable has one writer in Zeroth, so each place gets its own module. - -Point at a line in either editor, or at a node in the preview, to light what matches it. +Point at a node or a line to highlight its source and generated Python. diff --git a/pocs/zeroth-playground/examples/examples.test.ts b/pocs/zeroth-playground/examples/examples.test.ts index c01a4d4d..0bab9d01 100644 --- a/pocs/zeroth-playground/examples/examples.test.ts +++ b/pocs/zeroth-playground/examples/examples.test.ts @@ -10,7 +10,7 @@ const OPENED = EXAMPLES.map((example) => ({ })); describe("the examples", () => { - it("each compile, or are refused as expected, under the options they open with", () => { + it("each compile under the options they open with", () => { // GIVEN every example compiled under the options it opens with // WHEN each outcome is read off its compilation const outcomes = Object.fromEntries( @@ -19,17 +19,13 @@ describe("the examples", () => { files.length > 0 ? "compiles" : errors.map((error) => error.code).join(","), ]), ); - // THEN the plain and stochastic nets compile, and the net with code waits for a parser + // THEN every presentation example compiles expect(outcomes).toEqual({ cycle: "compiles", conflict: "compiles", - capacity: "compiles", - arcs: "compiles", "birth-death": "compiles", "birth-death-clocked": "compiles", "cafe-queue": "compiles", - "conflict-clocked": "compiles", - bucket: "code-not-parsed", }); }); diff --git a/pocs/zeroth-playground/playground/README.md b/pocs/zeroth-playground/playground/README.md index 55ca6829..a5d9ff51 100644 --- a/pocs/zeroth-playground/playground/README.md +++ b/pocs/zeroth-playground/playground/README.md @@ -1,93 +1,34 @@ # Playground -The React app that shows the compiler at work: the IR editor, the Python it compiles to, a preview of the net, a documentation page per example, the Compiler view, and the Semantics view with the open questions. `pnpm build` writes it to one HTML file, `dist/index.html`, that opens from `file://`. +The app has two views: Examples and Questions. The build is one HTML file that opens from disk. ## Folders -One concept per file, kebab-case names, a file's private helpers in a folder of the same name, tests beside the file they test. - -The source sits in four folders at the root. `vite.config.ts` sets Vite's root to `playground/`, which imports the other three; the build still writes `dist/index.html` at the root. The playground imports only `compiler/index.ts` from the compiler, the examples through `examples/catalog.ts` and their pages, and the questions through `semantics/register.ts` and their pages. - | Folder | Holds | | --- | --- | -| `app/` | the shell: `App`, the URL route, the open document and its edits, the header with the view switch and the example picker | -| `playground-view/` | the Playground view: its grid, the module view with its file list and diagnostics, the compiler options panel, hover state, module edits | -| `compiler-view/` | the Compiler view: the guide, the pipeline graph, the stage list, the stage cards and their live samples, the options table | -| `semantics-view/` | the Semantics view: the question list by topic, the question card with its page and where it shows, the evidence compiled off the catalog | -| `options/` | how the compiler options read and change in the playground, shared by both views | -| `ui/` | the primitives the views share: `Panel`, `Switch`, `useFolds`, the resize target, `BackLink`, `CodeExcerpt` over `excerpt.ts`, the arrow-key step of a list | -| `editor/` | Monaco setup and theme, the `petri-net-ir` language and its completions, the provenance hover, line decorations, the React wrapper | -| `preview/` | the net graph, its elkjs layout, the SVG | -| `docs/` | the documentation view, the page registry, and the components it passes to every page: the `Figure` frame, the `Question` reference card, and `CodeBlock` for fenced code | -| `pointer/` | the hover dwell shared by both views | -| `theme/` | the tokens, the base and prose layers, fonts, the wordmark | -| `scripts/` | the screenshot script | - -`index.html` and `main.tsx` sit at the top of the folder. +| `app/` | The header, native example picker, routes, and document state. | +| `playground-view/` | Editable YAML, read-only Python, applicable options, and diagnostics. | +| `semantics-view/` | The question list and MDX bodies. | +| `options/` | The three presentation settings and option edits. | +| `docs/` | Example pages, fenced code, question links, and embedded nets. | +| `preview/` | The shared net graph, elkjs layout, and SVG renderer. | +| `editor/` | Monaco, YAML completion, and source highlighting. | +| `pointer/` | Hover timing. | +| `ui/` | Panels and resize controls. | +| `theme/` | Tokens, fonts, and shared styles. | +| `scripts/` | Screenshot capture. | ## Data flow -``` -App state: Document { exampleId, irText, options, module edits } - │ compile(irText, { options }) - ▼ -Compilation { ir, options, irTrace, graph, files, errors, warnings } - ├─► IR editor and module editor (Monaco) - ├─► Compiler options panel (optionStates; withOption on a change) - ├─► Net preview (elkjs, SVG) - └─► Compiler view (one live sample per stage) - -Pointer over a line or node ─► useDwell ─► Hover { provenance } - ─► lit lines in both editors and the lit node in the preview - -App state: selectedQuestion ─► Semantics view - │ per showing: compile(example.ir, { options }) off the catalog - ▼ -Evidence { lines of the net item } or { the first diagnostic } - -NavigationContext { openQuestion, openExample } - ├─► a page's Question card opens the question in the Semantics view - └─► a showing's "Open in Playground" opens the example with its options -``` - -- `App` in `app/app.tsx` holds the document and compiles it during render. `app/router.ts` keeps the route in step with the URL hash, `app/route.ts` reads a hash as a route and writes a route as a hash, and `app/document.ts` holds the document's pure transitions. -- `PlaygroundView` takes the document and hands back the next one, built with those transitions. `CompilerView` only reads it. -- The IR carries no options: the options panel holds them. -- The hash says where the app is: `#` opens the Playground view on that example, `#?shape=modular` opens it under other options, `#compiler/` the Compiler view, and `#semantics/` a question. Every link in the app is an ``, so Back and Forward work, and a link can be copied. A hash naming another example, or other options, rebuilds the document from the catalog; edits in the panel and the editors do not write the hash. A new IR text or new options drop the module edits, and Reset returns to the document as the hash opened it. -- All three views stay mounted under React's `Activity`, so each keeps its panel sizes, collapsed panels and pins. -- The Semantics view reads the register and the catalog alone, never the document. The selected question is the one the hash names, so a page's `Question` card opens it with a link, and the view switch returns to the question last shown. -- The playground passes no code parser, so the nets with code strings are refused with `code-not-parsed`. - -## Layout - -- The Playground view is a `react-resizable-panels` grid: the documentation as a full-height first column, the IR and the module side by side, the net preview under them. Dragging a gridline resizes a row or a column, and dragging the point where two cross resizes both. A hovered or dragged gridline shows a blue overlay with a deep-blue line. -- Every view is the same `Panel`. The documentation, the compiler options, the preview and the files collapse from the chevron in their head; collapsed against a side, a panel becomes a strip with its title written upright. -- The example picker is a popover anchored to its button and walked with the arrow keys. It lists the listed examples rung by rung and skips an empty rung; an unlisted example appears while it is open. -- `NetPreview` is the one component that takes a `PetriNetIr`, the boundary a canvas can replace. - -## The compiler options panel - -`optionStates` and `OPTIONS` from the compiler decide the values, the defaults and why an option does not apply. Two files sit on top: - -| File | Decides | -| --- | --- | -| `options/option-labels.ts` | labels, one-line hints, value labels, sections, which option nests under which | -| `options/option-edits.ts` | `withOption`, which turns a control's text into the options the panel stores, and `sameOptions` | - -The panel stores only what `optionsForNet` keeps: the options that apply to the net and differ from the default. An option that does not apply is greyed, its control reads "not used", and the reason replaces its hint. Its default has no effect on the net, so the control does not show it. The Compiler view's option table in `compiler-view/strategy-table.tsx` reads the defaults and what each option applies to from `OPTIONS`. - -## The Compiler view +`App` owns `{ exampleId, irText, options }` and compiles it during render. The result feeds the input trace, Python, diagnostics, and net preview. Hovering a line or node highlights the same net item in the other panels. -`compiler-view/stages.ts` describes each stage of the compiler's pipeline to readers: its call, what goes in and comes out, and the facts worth knowing. Keep it in step with the pipeline in [compiler/README.md](../compiler/README.md#pipeline). To add a stage: +Both views stay mounted under React's `Activity` to preserve panel sizes. Hashes select examples and questions. `#cycle?shape=modular` opens an example with different options. Old `#compiler/` links open Examples. Reset restores the text and options named by the route. -1. Add the stage to `STAGES` in `compiler-view/stages.ts`. -2. Place it and its edges in `compiler-view/pipeline-layout.ts`. -3. Write its live sample in `compiler-view/stage-sample/` and route it from `stage-sample.ts`. -4. Cover it in `stage-sample.test.ts` and `pipeline-layout.test.ts`. +## Presentation -## The Semantics view +The example explanation occupies the left column. YAML and Python are side by side, with the net below. Options is a native disclosure, closed initially. It offers module shape, coin time step, and conflict choice only when applicable. Other compiler options remain available through the compiler API and URL queries. -`semantics/register.ts` lists the questions by topic; [semantics/README.md](../semantics/README.md) says how to add one. The view's left panel is the list, grouped by `TOPICS`, with a status dot per row. The right panel shows the intro and the topics with their counts, or the selected question's card: its page, rendered with the same components as an example page, then "Shows in". Each showing compiles its example at its options with `compile`, independent of the open document, and `semantics-view/evidence.ts` quotes the lines `linesOfItem` gives for the net item, up to 14, or the first diagnostic when the compile refuses. "Open in Playground" opens the example in the Playground view with those options. `#semantics` opens the view, `#semantics/` a question. +Questions have a short list and one body, with the first question selected initially. `ExampleNet` embeds the shared net preview at 180 px high and links to the example. Each SVG owns its arrow and inhibitor markers, so multiple nets can appear on one page. The renderer accepts optional hover props; embedded nets need no hover state. ## Conventions @@ -96,7 +37,7 @@ The panel stores only what `optionsForNet` keeps: the options that apply to the - The React Compiler memoizes, through `@vitejs/plugin-react`'s `compiler` option with `oxc-transform-react`. Derive values in render and do not add `useMemo` or `useCallback`. - Every React Compiler diagnostic fails `pnpm build`, the dev server's module and the tests that import it: a ref read in render, `setState` in render, an effect that only derives state, a construct the compiler cannot memoize. `vite.config.ts` sets `panicThreshold: "all_errors"` and the stricter checks. Restructure the component rather than opting out. `"use no memo"` still compiles the function, and a diagnostic inside it is only logged. - Effects only synchronize systems React does not own. Today that is Monaco, in `editor/monaco-editor.tsx`. -- State is plain data with pure transition functions beside it, tested without the DOM. `app/document.ts`, `playground-view/module-edits.ts` and `pointer/pointer-motion.ts` are examples. +- State is plain data with pure transition functions beside it, tested without the DOM. `app/document.ts` and `pointer/pointer-motion.ts` are examples. - Comments say what the code does and why. Prose follows the house style: plain words, short sentences, no em dashes. ## Styles @@ -107,7 +48,7 @@ Styles are plain CSS. A component imports its own file, placed beside it, and ne | --- | --- | | `theme/tokens.css` | the cascade layers, and the tokens: colours, type sizes, spacing, radii, shadows, durations | | `theme/base.css` | the reset and the page, one focus ring, a button reset with no specificity, `.caps` for mono capitals, `.note` | -| `theme/prose.css` | `.docs`, the reading column of the pages and the guide, and `.prose`, the typography the stage card shares with them | +| `theme/prose.css` | `.docs` and `.prose`, the typography shared by examples and questions | - `main.tsx` imports the three before anything else, because `tokens.css` names the layers before any rule uses one. - The layers, lowest first, are `base`, `prose` and `vendor`. A plugin in `vite.config.ts` puts Monaco's own CSS in `vendor`. A component's rules sit in no layer, so they win over all three without a longer selector. @@ -143,7 +84,7 @@ pnpm build - Tests sit beside their files as `*.test.ts`, and vitest runs the ones under `compiler/`, `examples/` and `playground/`. The compiler's tests are described in [compiler/README.md](../compiler/README.md#tests), the examples' in [examples/README.md](../examples/README.md#tests). - Every test, in all three folders, reads as GIVEN, WHEN, THEN: a `// GIVEN` comment on the setup, `// WHEN` on the action, `// THEN` on the expectations. A test with no action goes from GIVEN to THEN. One THEN line sums up a group of `expect` calls. The test's name says the behaviour, such as "drops the texts on a new compilation and keeps the toggle". -- The playground's pure cores have their own tests: the route and the document, the option labels and edits, the IR completions, the net graph, the pipeline layout, the stage samples, module edits, pointer motion. +- The playground's pure cores have their own tests: the route and the document, the option labels and edits, the IR completions, the net graph, and pointer motion. - `docs/example-pages.test.tsx` renders every example page with the components the documentation view passes, and `docs/code-block.test.tsx` checks that a fenced block renders one row per line. - A test changed to make it pass is a changed contract. Say why in the commit message. @@ -152,12 +93,12 @@ pnpm build Check visible changes in a headless browser against the build: 1. `pnpm build`. -2. Write a throwaway Playwright script as `playground/scripts/.tmp.ts` that opens `file://.../dist/index.html#`, acts, and screenshots. `#compiler`, or `#compiler/capacity`, opens the Compiler view. +2. Write a throwaway Playwright script as `playground/scripts/.tmp.ts` that opens `file://.../dist/index.html#`, acts, and screenshots. `#semantics/transition-or-places-decide` opens a question with an embedded net. 3. Run it with `node playground/scripts/.tmp.ts`, read the screenshots, then delete the script. Node 24 runs TypeScript as is, as `pnpm screenshot` does. Notes: - Playwright 1.63 drives its own Chromium 153. Run `pnpm exec playwright install chromium` once after `pnpm install`, and again after a Playwright upgrade; it downloads about 170 MB. -- All three views stay mounted, so filter selectors with `:visible`. +- Both views stay mounted, so filter selectors with `:visible`. - Monaco renders spaces as non-breaking spaces in `innerText`; normalize before comparing text. -- `pnpm screenshot` captures the examples the picker lists, the Compiler view and the Semantics view, with and without a question, at 1440×900 into `screenshots/`. +- `pnpm screenshot` captures all examples and questions, at 1440×900 into `screenshots/`. diff --git a/pocs/zeroth-playground/playground/app/app.tsx b/pocs/zeroth-playground/playground/app/app.tsx index df63583f..6c887e87 100644 --- a/pocs/zeroth-playground/playground/app/app.tsx +++ b/pocs/zeroth-playground/playground/app/app.tsx @@ -2,7 +2,6 @@ import { Activity, useState } from "react"; import { compile } from "../../compiler"; import { FIRST_EXAMPLE, exampleById } from "../../examples/catalog"; -import { CompilerView } from "../compiler-view/compiler-view"; import { sameOptions } from "../options/option-edits"; import { PlaygroundView } from "../playground-view/playground-view"; import { useDwell } from "../pointer/use-dwell"; @@ -19,7 +18,6 @@ import type { Hover } from "../playground-view/hover"; import "./app.css"; -/** The example the Playground and Compiler views work on, and the options it opened with. */ type Entry = { example: Example; options: CompilerOptions }; /** The entry a route names, or the current one when the route names no example. */ @@ -35,18 +33,11 @@ function sameEntry(a: Entry, b: Entry): boolean { } /** A view's hash on the entry, carrying the options only where they differ from the example's own. */ -function targetOf(view: "playground" | "compiler", entry: Entry): string { +function targetOf(entry: Entry): string { const options = sameOptions(entry.options, entry.example.options) ? {} : { options: entry.options }; - return hashOf({ view, example: entry.example, ...options }); + return hashOf({ view: "playground", example: entry.example, ...options }); } -/** - * The playground: the header over the three views. The URL hash says where - * the app is, through `useRoute`; the app renders the view it names and keeps - * the document of the example it names, compiled once per render for the two - * views that read it. Each view is hidden, not unmounted, while another - * shows, so folds, sizes and pins survive the round trip. - */ export const App: React.FC = () => { const route = useRoute(); const [entry, setEntry] = useState(() => @@ -73,8 +64,7 @@ export const App: React.FC = () => { const opening = documentOf(entry.example, entry.options); const compilation = compile(document.irText, { options: document.options }); const targets: Record = { - playground: targetOf("playground", entry), - compiler: targetOf("compiler", entry), + playground: targetOf(entry), semantics: hashOf({ view: "semantics", ...(lastQuestion === null ? {} : { question: lastQuestion }) }), }; @@ -83,7 +73,7 @@ export const App: React.FC = () => { const example = exampleById(id); if (example !== undefined) { pointAt(null); - navigate({ view: route.view === "compiler" ? "compiler" : "playground", example }); + navigate({ view: "playground", example }); } } @@ -97,16 +87,6 @@ export const App: React.FC = () => { onSelectExample={selectExample} onReset={() => setDocument(opening)} /> - -
    - -
    -
    {
    - +
    diff --git a/pocs/zeroth-playground/playground/app/document.test.ts b/pocs/zeroth-playground/playground/app/document.test.ts index 67e8ee27..ffb658ef 100644 --- a/pocs/zeroth-playground/playground/app/document.test.ts +++ b/pocs/zeroth-playground/playground/app/document.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { parsePetriNetIr } from "../../compiler"; import { exampleById } from "../../examples/catalog"; -import { editFile, toggleEditing } from "../playground-view/module-edits"; import { documentOf, isChanged, withIrText, withOptionText } from "./document"; import type { Example } from "../../examples/catalog"; @@ -22,30 +21,25 @@ describe("the document", () => { // GIVEN Birth–death under coins // WHEN its document is opened const document = documentOf(birthDeath); - // THEN it holds the example's text and options, no edits, and counts as unchanged + // THEN it holds the example's text and options, and counts as unchanged expect(document).toEqual({ exampleId: "birth-death", irText: birthDeath.ir, options: birthDeath.options, - module: { editing: false, texts: {} }, }); expect(isChanged(document, documentOf(birthDeath))).toBe(false); }); - it("drops the module edits on a new text, and keeps the document on the same text", () => { - // GIVEN Birth–death with a hand edit to net.py - const edited = { - ...documentOf(birthDeath), - module: editFile(toggleEditing(documentOf(birthDeath).module), "net.py", "# edited", "# compiled"), - }; - // WHEN the same text, then a new one, comes in - const same = withIrText(edited, birthDeath.ir); - const changed = withIrText(edited, `${birthDeath.ir}\n`); - // THEN the same text changes nothing; a new one drops the edits, keeps the toggle, and counts as a change - expect(same).toBe(edited); - expect(changed.module).toEqual({ editing: true, texts: {} }); - expect(isChanged(edited, documentOf(birthDeath))).toBe(true); - expect(isChanged(changed, documentOf(birthDeath))).toBe(true); + it("keeps the document for the same text and records a new text", () => { + // GIVEN an open example + const document = documentOf(birthDeath); + // WHEN the same text and a new text arrive + const same = withIrText(document, birthDeath.ir); + const changed = withIrText(document, `${birthDeath.ir}\n`); + // THEN unchanged text preserves identity and new text counts as a change + expect(same).toBe(document); + expect(changed.irText).toBe(`${birthDeath.ir}\n`); + expect(isChanged(changed, document)).toBe(true); }); it("stores an option the panel changes, and counts the default back as unchanged", () => { diff --git a/pocs/zeroth-playground/playground/app/document.ts b/pocs/zeroth-playground/playground/app/document.ts index 222cdb12..7533e83b 100644 --- a/pocs/zeroth-playground/playground/app/document.ts +++ b/pocs/zeroth-playground/playground/app/document.ts @@ -1,20 +1,12 @@ -import { type ModuleEdits, NO_EDITS, isEdited, recompiled } from "../playground-view/module-edits"; import { sameOptions, withOption } from "../options/option-edits"; import type { CompilerOptions, OptionName, PetriNetIr } from "../../compiler"; import type { Example } from "../../examples/catalog"; -/** - * What the playground has open: the IR text and the compiler options beside - * it, since the text carries no options. The example it was loaded from - * names the page. The module edits ride along: a new IR text or new options - * compile a new module and drop them. - */ export type Document = { exampleId: string; irText: string; options: CompilerOptions; - module: ModuleEdits; }; /** The example as it opens, under the options given in place of its own. */ @@ -23,27 +15,22 @@ export function documentOf(example: Example, options: CompilerOptions = example. exampleId: example.id, irText: example.ir, options, - module: NO_EDITS, }; } -/** Whether the text, the options or the module differ from the document as it opened. */ export function isChanged(document: Document, opening: Document): boolean { return ( document.irText !== opening.irText || - !sameOptions(document.options, opening.options) || - isEdited(document.module) + !sameOptions(document.options, opening.options) ); } -/** The document with a new IR text, which drops the module edits; the same text keeps it as it is. */ export function withIrText(document: Document, irText: string): Document { return irText === document.irText ? document - : { ...document, irText, module: recompiled(document.module) }; + : { ...document, irText }; } -/** The document with one option changed from a control's text, which drops the module edits. */ export function withOptionText( document: Document, ir: PetriNetIr, @@ -53,6 +40,5 @@ export function withOptionText( return { ...document, options: withOption(ir, document.options, name, text), - module: recompiled(document.module), }; } diff --git a/pocs/zeroth-playground/playground/app/example-picker.css b/pocs/zeroth-playground/playground/app/example-picker.css index f91dcb0e..0add6cd8 100644 --- a/pocs/zeroth-playground/playground/app/example-picker.css +++ b/pocs/zeroth-playground/playground/app/example-picker.css @@ -1,172 +1,12 @@ -/* The trigger centres its label and chevron; inside the label, the feature and the net share a baseline. */ .picker__trigger { - anchor-name: --example-picker; - display: inline-flex; - align-items: center; - gap: 0.55rem; - max-width: 24rem; - height: var(--control-height); - padding: 0 0.7rem 0 0.95rem; - border-radius: var(--control-radius); - corner-shape: squircle; - background: var(--paper-2); - box-shadow: var(--shadow-lift); - font-size: var(--fs-md); - line-height: 1; - transition: background-color var(--fast) var(--ease); - - &:hover { - background: color-mix(in oklab, var(--paper-2) 94%, var(--ink)); - } -} - -.picker__label { - display: inline-flex; - align-items: baseline; - gap: 0.55rem; - min-width: 0; -} - -.picker__feature { - overflow: hidden; - font-weight: 600; - white-space: nowrap; - text-overflow: ellipsis; -} - -.picker__net { - flex: 0 0 auto; - color: var(--ink-2); - font-family: var(--mono); - font-size: var(--fs-label); -} - -.picker__chevron { - flex: 0 0 auto; - align-self: center; - width: 0.4rem; - height: 0.4rem; - margin: 0 0.15rem 0 0.1rem; - border-right: 1.25px solid var(--ink-2); - border-bottom: 1.25px solid var(--ink-2); - /* Rotated, the two borders draw a V whose ink sits low in its box: lift it to the centre. */ - transform: translateY(-0.1rem) rotate(45deg); - transition: transform var(--base) var(--ease); - - .picker__trigger:has(+ .picker__list:popover-open) & { - transform: translateY(0.1rem) rotate(-135deg); - } -} - -/* The list sits under the button, right-aligned to it, and fades in. The footer closes it at the bottom. */ -.picker__list { - position-anchor: --example-picker; - inset: auto; - top: anchor(bottom); - right: anchor(right); - width: min(20rem, calc(100vw - 2rem)); - max-height: min(80vh, 32rem); - margin: 0.35rem 0 0; - padding: var(--s-1) var(--s-1) 0; + max-width: 100%; + height: 2rem; + padding: 0 0.75rem; border: 1px solid var(--hair); - border-radius: var(--r-md); + border-radius: var(--r-sm); background: var(--paper-2); - /* A popover takes the system's text colour unless told otherwise. */ - color: var(--ink); - box-shadow: var(--shadow-pop); - overflow-y: auto; - opacity: 0; - translate: 0 -0.25rem; - transition: - opacity var(--fast) var(--ease), - translate var(--base) var(--ease), - overlay var(--base) allow-discrete, - display var(--base) allow-discrete; - - &:popover-open { - opacity: 1; - translate: 0 0; - - @starting-style { - opacity: 0; - translate: 0 -0.25rem; - } - } -} - -/* A rung's title recedes behind its examples: small capitals in a lighter grey, in line with the features. */ -.picker__group-title { - padding: 0.6rem 0.6rem 0.2rem 1.6rem; - color: var(--ink-3); - font-size: var(--fs-label-sm); -} - -.picker__option { - display: grid; - grid-template-columns: 1rem minmax(0, 1fr); - align-items: center; - gap: var(--s-1); - width: 100%; - height: 1.875rem; - padding: 0 0.6rem 0 0.35rem; - border-radius: 0.5rem; - font-size: 0.82rem; - font-weight: 500; - text-align: left; - transition: background-color var(--fast) var(--ease); - - /* The pointer greys a row and the keyboard washes it in the accent, so the two never look alike. */ - &:hover { - background: var(--hair-2); - } - - &:focus-visible { - outline: none; - background: var(--accent-wash); - } - - /* The open example: a check and a heavier weight, never a fill, so hover and focus still show on it. */ - &[aria-selected="true"] { - font-weight: 600; - } - - & .picker__feature { - font-weight: inherit; - } -} - -/* Two borders of a box turned a quarter back draw the check, as two borders turned forward draw the chevron. */ -.picker__check { - justify-self: center; - width: 0.5rem; - height: 0.28rem; - border-bottom: 1.5px solid var(--accent); - border-left: 1.5px solid var(--accent); - rotate: -45deg; - translate: 0 -0.08rem; - opacity: 0; - - .picker__option[aria-selected="true"] & { - opacity: 1; - } -} - -/* The net and the summary of the example under the pointer, else under the keyboard focus. */ -.picker__about { - position: sticky; - bottom: 0; - min-height: 4.4rem; - margin: var(--s-1) -0.25rem 0; - padding: 0.55rem 0.95rem 0.6rem; - border-top: 1px solid var(--hair-2); - background: var(--paper-2); - color: var(--ink-2); - font-size: 0.76rem; -} - -.picker__about-net { - display: block; - margin-bottom: 0.15rem; color: var(--ink); - font-size: var(--fs-label-sm); + font: inherit; + font-size: var(--fs-base); + cursor: pointer; } diff --git a/pocs/zeroth-playground/playground/app/example-picker.tsx b/pocs/zeroth-playground/playground/app/example-picker.tsx index a1ff32d9..807f5b20 100644 --- a/pocs/zeroth-playground/playground/app/example-picker.tsx +++ b/pocs/zeroth-playground/playground/app/example-picker.tsx @@ -1,135 +1,14 @@ -import { useState } from "react"; - -import { EXAMPLES, LADDER, exampleById } from "../../examples/catalog"; -import { stepIndex } from "../ui/roving"; +import { EXAMPLES } from "../../examples/catalog"; import "./example-picker.css"; -type ExamplePickerProps = { - exampleId: string; - onSelect: (id: string) => void; -}; - -const LIST_ID = "example-list"; - -/** The options of the open list, in order, for the arrow keys. */ -function optionsOf(list: HTMLElement): HTMLButtonElement[] { - return [...list.querySelectorAll('[role="option"]')]; -} - -/** - * Arrow keys walk the options; Home and End jump to the ends. Tab closes the - * list, which hands the focus back to the button, and moves on from there. - * Escape and outside clicks are the popover's. - */ -function walk(event: React.KeyboardEvent) { - if (event.key === "Tab") { - event.currentTarget.hidePopover(); - return; - } - const options = optionsOf(event.currentTarget); - const at = options.indexOf(document.activeElement as HTMLButtonElement); - const next = stepIndex(event.key, at, options.length); - if (next === null) { - return; - } - event.preventDefault(); - options[next]?.focus(); -} - -/** - * The example picker: a button naming the open example by what it tackles - * and the net it uses, and a popover listing the examples up the ladder, - * rung by rung, one line each. A check marks the open one. The footer names - * the net and the summary of the example under the pointer or the keyboard - * focus. Unlisted examples appear only while open. The popover is anchored - * under the button by CSS anchor positioning, and closes on a choice, - * Escape, Tab or a click outside. - */ -export const ExamplePicker: React.FC = ({ exampleId, onSelect }) => { - // The option holding the keyboard focus, the one Tab would reach. - const [focused, setFocused] = useState(null); - // The option the footer describes: the last one hovered or focused. - const [active, setActive] = useState(null); - const current = exampleById(exampleId); - const shown = EXAMPLES.filter((example) => example.listed || example.id === exampleId); - const about = exampleById(active ?? exampleId); - const tabStop = focused ?? exampleId; - - function toggle(event: React.ToggleEvent) { - if (event.newState === "open") { - // Opening the list puts the focus on the example already open. - event.currentTarget.querySelector('[aria-selected="true"]')?.focus(); - } else { - setFocused(null); - setActive(null); - } - } - - return ( - <> - -
    setActive(focused)} - > - {LADDER.map((rung) => { - const examples = shown.filter((example) => example.rung === rung.id); - const titleId = `${LIST_ID}-${rung.id}`; - return examples.length === 0 ? null : ( -
    -
    - {rung.title} -
    - {examples.map((example) => ( - - ))} -
    - ); - })} - -
    - - ); -}; +export const ExamplePicker: React.FC<{ exampleId: string; onSelect: (id: string) => void }> = ({ exampleId, onSelect }) => ( + +); diff --git a/pocs/zeroth-playground/playground/app/header.tsx b/pocs/zeroth-playground/playground/app/header.tsx index 7b85d40a..ce1db85e 100644 --- a/pocs/zeroth-playground/playground/app/header.tsx +++ b/pocs/zeroth-playground/playground/app/header.tsx @@ -17,9 +17,6 @@ type HeaderProps = { onReset: () => void; }; -/** The views that work on the example the picker names; the Semantics view does not. */ -const EXAMPLE_VIEWS: readonly View[] = ["playground", "compiler"]; - /** A counter-clockwise arrow: back to how the example opened. */ const ResetIcon: React.FC = () => { return ( @@ -47,13 +44,12 @@ export const Header: React.FC = ({ - {/* The picker names the example the Playground and Compiler views work on; the Semantics view is general. */} -