diff --git a/.machine_readable/STATE.a2ml b/.machine_readable/STATE.a2ml index e76237a..1df6078 100644 --- a/.machine_readable/STATE.a2ml +++ b/.machine_readable/STATE.a2ml @@ -1,27 +1,41 @@ ;; SPDX-License-Identifier: MPL-2.0 -;; Project state — update throughout each session +;; Copyright (c) 2026 Jonathan D.A. Jewell +;; Project state — update throughout each session. (state (metadata - (version "1.0.1") - (project "rsr-template-repo") - (last-updated "2026-04-04")) + (version "2.0.0-dev") + (project "kitchenspeak") + (last-updated "2026-06-15")) (project-context - (description "RSR Standard Repository Template — baseline for all hyperpolymath projects") - (primary-language "Idris2 (ABI) + Zig (FFI)") - (status "testing-complete")) + (description + "KitchenSpeak — a formally-verified, hardware-agnostic orchestration DSL \ +for the domestic kitchen and consumer robotics. The v2.0 line elevates the \ +seven teaching types into a physically-grounded substructural type system: \ +materials carry thermal/mechanical/chemical envelopes and appliances carry \ +capability envelopes, so physical-safety violations (curdle, scorch, \ +over-torque, melt-the-tool) are compile errors, not firmware run-time faults.") + (primary-language "OCaml (front end + checker) + Agda/Lean (proofs) + Idris2/Zig (ABI/FFI)") + (status "v1.0 spec stable; v2.0 design + reference prototype in progress")) (current-position - (phase "testing") - (completion-percentage 100) - (milestone "CRG C - Testing & Benchmarking complete")) - (testing-summary - (validation-script "scripts/validate-template.sh: PASS (0 errors)") - (workflow-tests "tests/workflows/validate_workflows_test.sh: PASS (21/21 workflows)") - (integration-tests "test/integration_test.zig: PASS (placeholder template)") - (e2e-tests "tests/e2e/template_instantiation_test.sh: READY") - (benchmarks "benches/template_bench.sh: PASS (5 suites)") - (zig-build "Zig 0.15.2 compatible: PASS")) + (phase "v2.0 elevation: blueprint + reference prototype LANDED; PR #1 open") + (completion-percentage 60) + (milestone "Blueprint docs, OCaml reference checker (corpus 15/15), and the \ +machine-checked NoCurdle proof are committed (signed), pushed, and open as PR #1. \ +Remaining work is depth: session-typing/effects in the checker, more proofs, \ +real HAL adapters.")) + (heritage + (note "This repo was instantiated from rsr-template-repo and previously \ +retained the template's STATE identity. Corrected 2026-06-15 to its true \ +identity. The design substance (proofs, ROADMAP, ADRs 0002-0004) was \ +re-homed from the nextgen-languages monorepo copy in the same session.")) + (done-2026-06-15 + ("docs/spec + docs/design blueprint landed (constitution, grammar, type system, semantics, HAL, ontology, index)." + "src/compiler/ OCaml checker built; corpus tests/run-corpus.sh 15/15 (5 accept, 10 reject-with-expected-code)." + "proofs/agda/NoCurdle.agda machine-checked (exit 0) + wired into proofs/Makefile." + "Committed (signed id_ed25519_signing), pushed, PR #1 open, GitHub Verified.")) (critical-next-actions - ("Commit test suite" - "Push to GitHub" - "Verify CI workflows pass" - "Document test instantiation patterns"))) + ("Implement choreographic deadlock-freedom + ceremony-commutation in src/compiler/check.ml (specified, not yet enforced)." + "Add Emulsion + Sear proofs (ROADMAP Phase 1c); then QTT-Agda linearity port; then Lean 4 port." + "Wire real HAL adapters (Matter/MQTT/ROS2) behind the trace IR the prototype already emits." + "Finish the adversarial completeness pass over the 6 blueprint docs (4 are workflow drafts; critic cut by spend cap)." + "Retire or repoint the old nextgen-languages/kitchenspeak copy now the standalone is canonical and ahead."))) diff --git a/.machine_readable/root-allow.txt b/.machine_readable/root-allow.txt index f811b2d..a9e8938 100644 --- a/.machine_readable/root-allow.txt +++ b/.machine_readable/root-allow.txt @@ -51,6 +51,16 @@ scripts/ verification/ container/ # may host Containerfile if not at build/ +# ─── Language artefacts (this repo IS a language, not a bare template) ──────── +# KitchenSpeak's authoritative spec family lives at root by convention, mirroring +# the nextgen-languages monorepo copy these were re-homed from (2026-06-15). +SPEC.adoc # class v1.0 specification (authoritative requirements) +COMMENTARY.adoc # type-theoretic companion to SPEC.adoc +grammar.ebnf # class grammar (Section A) + marked patches (Section B) +ROADMAP.adoc # proofs-first plan to full-toolchain parity +decisions/ # Architecture Decision Records (ADRs 0001-0004) +proofs/ # Agda (now) / Lean (later) lowerings of the recipes + # ─── Tolerated pending follow-up (re-evaluate when item lands) ─────────────── .gitlab-ci.yml # TODO: relocate to ci/.gitlab-ci.yml after GitLab project-setting update .pre-commit-config.yaml # TODO: relocate to ci/.pre-commit-config.yaml after invocation pattern decided diff --git a/CHANGELOG.md b/CHANGELOG.md index 8109476..c9c2497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + +### Added — v2.0 elevation (2026-06-15) + +- **Physically-grounded type system design.** `docs/design/0001-v2-constitution.adoc` + (binding contract) plus the blueprint: `docs/spec/` (lexical grammar, concrete + grammar v2, normative type system) and `docs/design/` (semantics & safety + calculus, HAL & hardware, standard ontology, blueprint index). Materials carry + thermal/mechanical/chemical envelopes and appliances carry capability envelopes + so physical-safety violations are compile errors. +- **Reference compiler** `src/compiler/` (`ksc`, dependency-free OCaml): lexer → + parser → typed AST → physical-safety checker → HAL lowering (Matter / MQTT / + ROS 2). `ksc check | lower | parse`. +- **Example corpus** `examples/v2/` (5 well-typed recipes) + `examples/v2/negative/` + (9 must-reject recipes) and `tests/run-corpus.sh` (14/14 green). +- **`proofs/agda/NoCurdle.agda`** — v2.0 flagship: machine-checked proof that a + bounded controller keeps milk below its curdle envelope and the milk phase never + becomes CURDLED. Wired into `proofs/Makefile`. + +### Fixed + +- **Re-homed the design substance** lost in the original excavation from the + nextgen-languages monorepo: `proofs/agda/{Dough,PoachedEgg,EchoBridge}`, + `proofs/Makefile`, ADRs 0002–0004, and `ROADMAP.adoc`. +- Corrected `.machine_readable/STATE.a2ml` identity (was still + `project "rsr-template-repo"`) and added the language artefacts to + `.machine_readable/root-allow.txt`. diff --git a/README.adoc b/README.adoc index 78a7c88..a637861 100644 --- a/README.adoc +++ b/README.adoc @@ -10,10 +10,43 @@ machine-executable actions for MQTT/Matter-class appliances, while maintaining physical and digital safety through a multi-layered type system. -*Status:* CLASS PROJECT. Specification stable at v1.0; grammar and worked -examples are in active development. Not intended for deployment on actual -kitchen hardware. Experimental companion to the other languages in this -repository. +*Status:* v1.0 specification stable (the original class teaching artefact). +*v2.0 elevation in progress* — a physically-grounded type system, a reference +compiler, and a machine-checked material-safety proof. Not intended for +deployment on actual kitchen hardware yet. + +== KitchenSpeak v2.0 — the production elevation + +v2.0 turns typing into the *superpower*: physical safety lives in the type +system, so a program that could *curdle the milk*, *scorch the egg*, *bake the +silicone spatula*, or *over-torque the robot arm* **fails to compile**. Materials +carry thermal/mechanical/chemical safety envelopes; appliances carry capability +envelopes; an action type-checks only inside the intersection of the two. The same +types lower to machine-checked Agda proofs *and* to a HAL over Matter / MQTT / +ROS 2. + +Start at `docs/design/0000-blueprint-index.adoc` (the document map and the +"golden thread" tracing no-curdle from syntax → type rule → proof → HAL). + +[source,bash] +---- +# Build and run the reference compiler (dependency-free OCaml; needs dune). +cd src/compiler && dune build +KSC=_build/default/main.exe + +$KSC check ../../examples/v2/poached-egg.ks # => OK +$KSC check ../../examples/v2/negative/curdle-milk.ks # => error [CURDLE] ... (exit 1) +$KSC lower --target matter ../../examples/v2/poached-egg.ks # => Matter trace JSON + +# Run the full accept/reject corpus (14 cases): +bash tests/run-corpus.sh + +# Machine-check the v2.0 flagship safety proof: +make -C proofs nocurdle # (or: agda proofs/agda/NoCurdle.agda) +---- + +Not intended for deployment on actual kitchen hardware. Experimental companion to +the other languages in this repository. == Origin diff --git a/ROADMAP.adoc b/ROADMAP.adoc new file mode 100644 index 0000000..c6ad719 --- /dev/null +++ b/ROADMAP.adoc @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak Roadmap — From Specification to AffineScript Parity +:toc: +:toclevels: 3 +:icons: font + +== Purpose + +KitchenSpeak has been elevated from an in-tree directory of the +`nextgen-languages` monorepo to a standalone canonical repository, on a +par with its sibling languages (`affinescript`, `ephapax`, `betlang`, …). +This roadmap defines the path that takes KitchenSpeak from its current +position — *a stable v1.0 specification plus a single hand-verified Agda +proof* — to the position **AffineScript** currently occupies: a full +language toolchain with an in-progress ABI and `RSR FULL` repository +hygiene. + +The ordering principle is **proofs first**. KitchenSpeak's defining +invariant (SPEC.adoc §6, "Agda-Proven") is that the core recipe library +type-checks with zero unsolved goals. Unlike a conventional language, +where proofs trail the compiler, KitchenSpeak's proofs *are* the +specification's acceptance test and must lead. This also happens to be +precisely where AffineScript is weakest — its own foundations grade is +`FRG-E` with no `formal/` directory and an open type-soundness +obligation (see the monorepo's `PROOF-NEEDS.md`) — so leading with +proofs both honours KitchenSpeak's identity and lets it overtake its +target on the one axis that matters most for a safety DSL. + +== Where AffineScript is (the target position) + +Recorded from the monorepo's own authoritative documents +(`EXPLAINME.adoc` §Claim 1, `TOOLING-STATUS.adoc`, `PROOF-NEEDS.md`), +since the standalone `hyperpolymath/affinescript` repo sits outside this +work's scope: + +[cols="1,3"] +|=== +| Axis | AffineScript's position + +| Front end +| OCaml compiler (lexer → parser → checker). Tree-sitter grammar. + +| Toolchain +| `affine-lsp` (Rust), `affine-dap` (Rust), `linter.ml`, formatter + (stub), `affine-doc`, `affine-pkg`. Conformance suite ✓. + +| ABI / FFI +| `src/abi/` Idris2 (`Types`, `Layout`, `Foreign`); Zig FFI planned. + Status: *in progress*. + +| Target +| typed WebAssembly (WasmGC) via the shared `typed-wasm` aggregate. + +| Grades +| `ARG-D`, `FRG-E`, `RSR FULL`. Registered in BoJ `lang-mcp` (id 2). + +| Formal verification +| *Known gap.* Claims affine types but the OCaml type checker is + unproven and the Rust runtime (GC, allocator) is unverified + (`PROOF-NEEDS.md`). No `formal/` directory. +|=== + +== Where KitchenSpeak is (the starting position) + +[cols="1,3"] +|=== +| Artefact | Status + +| `SPEC.adoc` +| Class v1.0 specification, stable. Authoritative requirements artefact. + +| `grammar.ebnf` +| Class grammar (Section A) + three marked additive patches (Section B). + Lexical sub-grammar still placeholder (open item O3). + +| `COMMENTARY.adoc` +| Type-theoretic companion: each of the seven types mapped to standard + foundations; proof obligations named. + +| `examples/poached-egg.ks` +| One worked example (Linear + Tropical + Echo + Ceremonial). + +| `decisions/0001-proof-assistant.adoc` +| ADR: Agda chosen for v1.0, Lean 4 reserved as successor. + +| `proofs/agda/Dough.agda` +| One proof (Linear + Dyadic + Choreographic + Echo). Hand-verified, + not yet machine-checked (no Agda in CI yet). + +| Toolchain +| None. No lexer, parser, type checker, LSP, DAP, formatter, package + manager, doc generator, ABI, or conformance suite. + +| Grades +| All `TBD` (`TOOLING-STATUS.adoc`). No `spec/{ARG,FRG,TRG}-PROFILE`. +|=== + +== Phased plan + +=== Phase 0 — Repository elevation & hygiene *(this PR)* + +* Standalone repo created with `README.adoc`, `LICENSE` (Palimpsest / + MPL-2.0), `.gitignore`, `CHANGELOG.adoc`, this `ROADMAP.adoc`. +* `ADR 0002` records the elevation and this roadmap's rationale. +* Wired back into `nextgen-languages` as a submodule, matching every + other language. +* *Exit criteria:* repo builds its proofs locally (`make -C proofs`); + listed as a DSL in the monorepo `README.adoc` and `EXPLAINME.adoc`. + +=== Phase 1 — Proofs first *(the core deliverable)* + +The `proving @witness` clause and the `Agda-Proven` mission criterion +mean the proof library is the language's acceptance test. This phase is +deliberately ahead of any compiler work. + +* *1a. Machine-check the existing library.* Add Agda 2.6.4+ / stdlib + 2.0+ to CI (`proofs/Makefile`, `kitchenspeak.agda-lib` landed in this + PR). Bring `Dough.agda` from *hand-verified* to *machine-checked*, + zero unsolved goals. +* *1b. PoachedEgg.agda* — landed in this PR. Lowers + `examples/poached-egg.ks`; first proof to discharge a *Tropical* + refinement (no-binary-overheat envelope) alongside Linear + Echo. +* *1c. Complete the SPEC §6 core library.* Add `Emulsion.agda` + (Tropical + Linear) and `Sear.agda` (Tropical + Echo + Primitive) so + the three canonically-named recipes — Dough, Emulsion, Sear — all + type-check. +* *1d. Linearity.* Move from the by-inspection discipline (Dough/Poached + Egg §Linearity notes) to a machine-checked QTT-Agda port, resolving + ADR 0001 open question 1. +* *1e. Foundations grade.* Author `spec/FRG-PROFILE.adoc`. Target + `FRG ≥ E` immediately (a real `proofs/` tree exists, which AffineScript + lacks), climbing toward `FRG-D` as the library closes. +* *Exit criteria:* `make -C proofs` green in CI for the whole core + library; FRG profile authored. + +=== Phase 2 — Front end (lexer + parser) + +* Formal lexer specification, closing `grammar.ebnf` open item O3 + (the placeholder lexical nonterminals). +* Parser to a typed AST. Implementation language **OCaml**, matching + AffineScript and the monorepo's compiler-front-end convention. +* Resolve the remaining grammar open items O1 (nested blocks) and O2 + (`resource_decl` / `chef_decl` / `echo_decl` definitions). +* *Exit criteria:* `examples/poached-egg.ks` and the Dough/Emulsion/Sear + programs parse to AST; round-trips through a pretty-printer. + +=== Phase 3 — Type checker (static semantics) + +Implement the seven types as a checker, cross-validated against the Agda +proofs from Phase 1 (the proofs are the oracle for the checker's +soundness): + +* Tropical — bounded-derivative refinement over dimensioned quantities. +* Linear — single-use resource discipline (QTT-style). +* Choreographic — session-typed multiparty barriers; deadlock-freedom. +* Echo — postulated-oracle witnesses bound by `proving`. +* Dyadic — tensor product for `<~>` binds. +* Ceremonial — ambient reader-effect, proven to commute with linearity. +* Primitive — SI units-of-measure. +* Enforce the three production-mandatory constructs (`max_duration`, + `on_fail`, `proving`) as typing rules. +* *Exit criteria:* checker rejects the negative test corpus (double-used + egg, missing `on_fail`, unwitnessed `sync`); `ARG ≥ D` plausible. + +=== Phase 4 — Lowering & Hardware Abstraction Layer + +* Lower well-typed programs to the device-command JSON of SPEC §5 + (HomeConnect / SmartThings / Tuya); Matter + MQTT as a later HAL + revision. +* Echo postulates become HAL sensor bindings; the proven/postulated + boundary becomes a runtime trust boundary. +* *Exit criteria:* the Poached Egg lowers to a HAL trace for at least + one simulated target. + +=== Phase 5 — Toolchain parity with AffineScript + +Build out, one-for-one against AffineScript's `TOOLING-STATUS` row: + +* `ks-lsp` (LSP), `ks-dap` (DAP) — Rust, per convention. +* Linter, formatter, tree-sitter grammar, doc generator, package + manager. +* Conformance suite + fuzzing. +* *Exit criteria:* the AffineScript tooling row is matched cell-for-cell. + +=== Phase 6 — ABI / FFI + +* `src/abi/` in Idris2 (`Types`, `Layout`, `Foreign`); Zig FFI — exactly + mirroring AffineScript's in-progress ABI. +* *Exit criteria:* ABI status reaches *in progress* parity. + +=== Phase 7 — Grades, registry, RSR FULL + +* Author `spec/{ARG,FRG,TRG}-PROFILE.adoc`; honour the cross-axis + invariants (`ARG ≤ TRG`; `ARG-A` requires `FRG ≥ B`). +* Reach `RSR FULL` repository hygiene. +* Register in the BoJ `lang-mcp` cartridge; add a row to + `language-status-tracker.jl`. +* *Exit criteria:* `TOOLING-STATUS.adoc` shows KitchenSpeak at + AffineScript's grade row or better, with `FRG` strictly ahead. + +== Parity scorecard + +The single-glance "are we there yet" against the target. Updated as +phases close. + +[cols="2,1,1,1", options="header"] +|=== +| Axis | AffineScript | KitchenSpeak (now) | KitchenSpeak (target) + +| Proofs / `formal` | none (gap) | Dough + PoachedEgg (hand-verified) | core library machine-checked +| Front end | OCaml + parser | grammar.ebnf only | OCaml lexer + parser +| Type checker | OCaml | — | seven-type checker +| LSP / DAP | Rust ✓ | — | ks-lsp / ks-dap +| Linter / formatter | ✓ / stub | — | ✓ / ✓ +| Tree-sitter | ✓ | — | ✓ +| Doc gen / pkg mgr | ✓ / ✓ | — | ✓ / ✓ +| ABI (Idris2 + Zig) | in progress | — | in progress +| FRG | E | TBD | ≥ E, climbing to D +| RSR | FULL | TBD | FULL +|=== + +== Status + +Phase 0 complete; Phase 1 commenced (1a harness + 1b PoachedEgg landed). +Remaining work tracked per-phase above and mirrored in the monorepo's +`TOOLING-STATUS.adoc` KitchenSpeak row. diff --git a/decisions/0002-elevation-to-standalone-repo.adoc b/decisions/0002-elevation-to-standalone-repo.adoc new file mode 100644 index 0000000..7444ceb --- /dev/null +++ b/decisions/0002-elevation-to-standalone-repo.adoc @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += ADR 0002 — Elevation of KitchenSpeak to a Standalone Repository +:toc: +:toclevels: 2 +:icons: font + +== Status + +*Accepted — content prepared; remote wiring pending.* + +The decision is accepted and all content is in place (standalone scaffolding, +roadmap, proof harness, and the PoachedEgg proof). The final mechanical step — +creating `github.com/hyperpolymath/kitchenspeak` and converting the +`kitchenspeak/` directory into a submodule — is performed by +`scripts/elevate-kitchenspeak.sh`. It is split out because it requires network +access and a credential allowed to create repositories, which the automated +session that prepared this branch lacked (repo creation returned HTTP 403, +"Resource not accessible by integration"). Until that script runs, the content +lives in-tree in the monorepo. + +== Context + +KitchenSpeak began life as an in-tree directory, `kitchenspeak/`, inside +the `nextgen-languages` monorepo — the only language there held as plain +tracked files rather than as a submodule of its own canonical repo. Every +sibling language (`affinescript`, `ephapax`, `betlang`, `eclexia`, +`oblibeny`, …) is a standalone `hyperpolymath/` repository wired +into the monorepo as a git submodule. + +KitchenSpeak having matured to a stable v1.0 specification, a patched +grammar, a type-theoretic commentary, a worked example, and a first +proof, it is ready to take its place alongside the others. Two things +prompted the elevation: + +. *Consistency.* The monorepo's own `EXPLAINME.adoc` states that "no + implementation code lives [at the repo root]. All language + implementations live in subdirectories … which are also maintained as + standalone canonical repos." KitchenSpeak was the exception. +. *Trajectory.* KitchenSpeak now has a development roadmap of its own + (`ROADMAP.adoc`) aiming at AffineScript-level toolchain parity. That + work wants its own issue tracker, CI, and release cadence — i.e. its + own repo. + +== Decision + +Elevate KitchenSpeak to `hyperpolymath/kitchenspeak`, a standalone +public repository, and reference it from `nextgen-languages` as a git +submodule (`git@github.com:hyperpolymath/kitchenspeak.git`), exactly as +the sibling languages are referenced. + +The elevation carries the existing artefacts verbatim (`SPEC.adoc`, +`COMMENTARY.adoc`, `grammar.ebnf`, the worked example, ADR 0001, the +Dough proof) and adds standalone-repo scaffolding: `README.adoc`, +`LICENSE`, `.gitignore`, `CHANGELOG.adoc`, `ROADMAP.adoc`, a proof build +harness (`proofs/Makefile`, `proofs/agda/kitchenspeak.agda-lib`), and a +second proof (`proofs/agda/PoachedEgg.agda`) that commences Phase 1 of +the roadmap. + +== Consequences + +* The monorepo no longer holds KitchenSpeak's files directly; it holds a + submodule gitlink and a `.gitmodules` entry. +* KitchenSpeak is listed as a DSL in the monorepo `README.adoc` Projects + list and `EXPLAINME.adoc` language tables. +* Development now leads with **proofs** (`ROADMAP.adoc` Phase 1), in + keeping with the `Agda-Proven` mission criterion (SPEC.adoc §6) and in + deliberate contrast to AffineScript, whose foundations are its weakest + axis. +* The `SPEC.adoc` class artefact is untouched by the move — elevation is + a packaging decision, not a specification change. + +== Reversibility + +*High.* The submodule pointer can be inlined again, or the repo +re-absorbed, without touching any specification or proof content. Nothing +in `SPEC.adoc`, `grammar.ebnf`, or the proofs depends on the repository +boundary. diff --git a/decisions/0003-echo-types-dependency.adoc b/decisions/0003-echo-types-dependency.adoc new file mode 100644 index 0000000..abd9b63 --- /dev/null +++ b/decisions/0003-echo-types-dependency.adoc @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += ADR 0003 — Base the Echo Type on the echo-types Library +:toc: +:toclevels: 2 +:icons: font + +== Status + +*Accepted — bridge landed; full in-proof adoption staged.* + +== Context + +KitchenSpeak's *Echo* type (`@`) models a postulated-oracle witness: a +sensor sample together with a proof the physical world matched intent. +Until now this was an ad-hoc dependent pair in each proof — e.g. +`Witness = Σ[ t ∈ Minutes ] viscosity-at t ≥ threshold` (Dough.agda) and +`ShimmerWitness = Σ[ t ] temp-at t ≥ threshold` (PoachedEgg.agda). + +A standalone, mechanised formalisation of exactly this structure already +exists in the ecosystem: link:https://github.com/hyperpolymath/echo-types[`hyperpolymath/echo-types`], +a constructive Agda library whose core type is the *fiber*: + +[source,agda] +---- +Echo : ∀ {a b} {A : Set a} {B : Set b} (f : A → B) → B → Set (a ⊔ b) +Echo f y = Σ A (λ x → f x ≡ y) +---- + +`Echo f y` records *which inputs map to `y`* — the proof-relevant +residue of a non-injective ("lossy") function. echo-types develops this +as an orthogonal factorization system with a thermodynamic (Landauer / +Bennett) layer and a residue taxonomy that includes an *epistemic* +residue form. The runnable, finite-domain companion is +link:https://github.com/hyperpolymath/EchoTypes.jl[`EchoTypes.jl`] (a +model, not a proof). + +KitchenSpeak's `@` witness is a special case of this fiber: the sensor +is a (non-injective, lossy) classifier and the witness is the Echo over +its "fired" value. Re-using echo-types gives KitchenSpeak's Echo a +single mechanised definition shared between the proofs and the planned +type checker, and aligns the cooking-irreversibility / thermodynamic +themes (you cannot un-cook; structured, non-total loss) with an existing +formal account. + +== Decision + +Adopt echo-types as the canonical foundation for KitchenSpeak's Echo +type, and depend on it from the proof library. + +. *Dependency.* `kitchenspeak.agda-lib` now declares + `depend: standard-library echo-types`. echo-types itself declares + `depend: standard-library absolute-zero`, so both `echo-types` and + `absolute-zero` must be registered in `~/.agda/libraries`, and + agda-stdlib is raised to 2.3+ (echo-types' floor). +. *Bridge.* A new `proofs/agda/EchoBridge.agda` imports echo-types' + `Echo` and proves KitchenSpeak's threshold witness interconvertible + with `Echo (fired sensor thr) true`, where + `fired s thr t = ⌊ s t ≥? thr ⌋` views the oracle as a Boolean + classifier. This isolates the cross-library integration in one file. +. *Proofs unchanged for now.* Dough.agda and PoachedEgg.agda keep their + inline `Σ` witnesses (so their structural-recursion proofs are + untouched) and expose them as echo-types Echoes via the bridge. + Migrating the inline witnesses to `SensorEcho` directly is staged for + a later step once the dependency type-checks in CI. +. *Type checker.* The planned KitchenSpeak checker (ROADMAP Phase 3) + takes `Echo (fired sensor thr) true` as the canonical typing of an `@` + witness, with EchoTypes.jl as the finite-domain runtime / HAL model. + +== Consequences + +* KitchenSpeak's Echo is no longer ad-hoc: it is the echo-types fiber, + with the structured-loss / epistemic-residue reading made explicit. +* New build burden: contributors must register `echo-types` and + `absolute-zero` (documented in `proofs/README.adoc`). CI (ROADMAP + Phase 1a) must fetch both before `make -C proofs`. +* Flag stance: echo-types' `Echo` is `--safe --without-K`; KitchenSpeak's + proofs are deliberately non-`--safe` (they postulate sensor streams). + Importing a safe/without-K module from non-safe code is permitted; the + bridge is left at default discipline and this is to be confirmed when + Agda is available. +* The "verified classifier story for echo-types" previously listed as + out of scope (COMMENTARY §Deliberately out of scope) is now partially + addressed: the *type* of the witness is mechanised; the sensor + *reading* remains postulated. + +== Reversibility + +*Moderate.* The bridge and the `depend` line are removable, restoring the +self-contained `Σ` witnesses, without touching the recipe proofs (which +were intentionally left unmodified). Reverting only loses the shared +definition and the documented alignment. diff --git a/decisions/0004-echo-attaches-to-linear-dyadic.adoc b/decisions/0004-echo-attaches-to-linear-dyadic.adoc new file mode 100644 index 0000000..41626d2 --- /dev/null +++ b/decisions/0004-echo-attaches-to-linear-dyadic.adoc @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += ADR 0004 — Echo Attaches to Linear/Dyadic Consumption (Q1) +:toc: +:toclevels: 2 +:icons: font + +== Status + +*Accepted (design). Implementation gated and staged.* + +This ADR records the answer to the standing design question "Q1": where, in +KitchenSpeak's type system, does the `hyperpolymath/echo-types` notion of +*structured, proof-relevant loss* attach? It is a design decision only — no +consumption-model code is changed here. The implementation is gated on the +echo-types repository being in scope so its decorated modules can be read +first (see Consequences). + +== Context + +ADR 0003 wired KitchenSpeak's Echo (`@`) type onto echo-types' core fiber +`Echo f y = Σ (x : A), f x ≡ y` via a bridge that treats each sensor as a +Boolean classifier (`fired s thr t = ⌊ s t ≥? thr ⌋`) and takes the Echo over +`true`. That bridge is merged as a checkpoint, but it is the *degenerate* +fiber: a yes/no tag, which is precisely the "generic Σ / Boolean collapse" +the project's global Echo principle warns against (Echo must carry +*retained-loss lineage*, not a decorative wrapper). + +Three candidate attachment points were considered: + +* *A — Echo (`@`) sensor-witness type.* Name- and shape-match; smallest + change. But a single sensor witness is one Echo, not a lineage chain; it + never exercises map-over / degrade-compose, and risks reducing Echo to a + yes/no tag. +* *B — Linear / Dyadic consumption types.* Concept-exact: consuming an egg, + or `Flour ⊗ Water → Dough`, is non-total erasure with retained + residue/provenance — echo-types' literal subject. The dyadic bind is a + non-injective map whose fiber is provenance; cooking stages + (`raw → seared → charred`) are degrade-compose chains. +* *C — a shared substrate beneath several KitchenSpeak types.* echo-types + ships `EchoLinear`, `EchoTropical`, `EchoChoreo`, `EchoEpistemic`, + `EchoGraded` — a decoration for most of KitchenSpeak's type zoo, hinting + Echo is the substrate, not one type. + +=== Weighted assessment + +Drivers and weights (sum 100); option scores 1–5; weighted total out of 500. + +[cols="<3,^1,^1,^1,^1", options="header"] +|=== +| Driver | Weight | A | B | C + +| Semantic fidelity — retained-loss lineage, not generic Σ/Bool | 35 | 2 | 5 | 5 +| KitchenSpeak identity — irreversible transform with provenance | 25 | 2 | 5 | 4 +| Effort / blast-radius safety (lower risk scores higher) | 15 | 5 | 2 | 1 +| Pedagogical clarity (distinct teaching lenses) | 15 | 4 | 3 | 2 +| Reversibility / avoids premature lock-in | 10 | 5 | 3 | 2 +| *Weighted total* | *100* | *305* | *405* | *340* +|=== + +B wins cleanly. The framing is deliberate: this is a "where does the concept +belong?" decision, not a minimal-patch decision, so fidelity and identity +dominate the weighting. + +== Decision + +*B-now / C-later / A-shim.* + +. *Primary attachment (B):* echo-types attaches first to KitchenSpeak's + *Linear* and *Dyadic* consumption types. That is where KitchenSpeak + naturally speaks about irreversible transformation, residue, provenance, + and degradation chains. KitchenSpeak's core identity is not "a sensor + fired and we kept a witness" — that is one instance — but *irreversible + transformation with retained provenance* (`Flour ⊗ Water → Dough`: you + cannot unmix the dough, but you may retain structured residue of the + transformation). +. *Substrate hypothesis (C), reserved:* `EchoLinear` / `EchoTropical` / + `EchoChoreo` / `EchoEpistemic` may eventually explain several KitchenSpeak + types. This is kept open as an architecture hypothesis and is *not* + implemented wholesale until the echo-types repo is in scope and those + modules are inspected properly. +. *Sensor witness (A), demoted to a shim:* the `@` / sensor-witness Echo may + remain as a secondary adapter/example, but is *not* the core attachment. + The current Boolean `EchoBridge` is a *temporary smoke-test bridge only* + (so annotated in `proofs/agda/EchoBridge.agda`), not the canonical Echo. + +== Consequences + +* The next real KitchenSpeak pass attaches echo-types to Linear/Dyadic, and + *replaces the Boolean classifier with genuine retained-loss + lineage/residue* — but only *after* inspecting the relevant echo-types + modules (`EchoLinear`, `EchoResidue`, `EchoResidueTaxonomy`, and the + decoration variants). That inspection requires `hyperpolymath/echo-types` + to be in this session's scope; until then, no consumption-model code is + written. +* Work lands on a *fresh branch* as follow-up commits; the merged + ADR-0003 / EchoBridge checkpoint is preserved, not rewritten. +* `Dough.agda` (the dyadic `bind-dough` + linear consumption) becomes the + natural first proof to carry a real provenance/residue Echo; the degrade + chain (`raw → … → over`) becomes the canonical lineage example — which + also feeds the echo-types benchmark work-stream (lineage chains drawn + from recipes). +* ROADMAP Phase 3 (type checker): the Echo typing rule is rooted in the + Linear/Dyadic residue, with `@` as an adapter, not the root. + +== Reversibility + +*High.* This is a recorded decision plus a one-paragraph status annotation on +the smoke-test bridge. No consumption-model code is committed, so nothing +needs unwinding if the substrate inspection (C) reshapes the plan. diff --git a/docs/design/0000-blueprint-index.adoc b/docs/design/0000-blueprint-index.adoc new file mode 100644 index 0000000..657ba42 --- /dev/null +++ b/docs/design/0000-blueprint-index.adoc @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — Blueprint Index +:toc: +:toclevels: 3 +:icons: font + +== Overview + +KitchenSpeak v2.0 elevates a seven-type *teaching* DSL into a production +orchestration language for the domestic kitchen and consumer robotics, whose +defining feature is that *physical safety lives in the type system*. Materials +carry thermal/mechanical/chemical safety envelopes; appliances carry capability +envelopes; an action type-checks only inside the *intersection* of the two. So a +program that could curdle the milk, scorch the egg, bake the silicone spatula, or +over-torque the robot arm **fails to compile** — it never reaches the hardware. +The same types lower to (a) machine-checked Agda proofs and (b) a hardware +abstraction layer over Matter, MQTT, and ROS 2. *Do the stuff, and prove the +stuff.* + +== Document map & reading order + +[cols="1,3", options="header"] +|=== +| Artefact | Purpose + +| `SPEC.adoc` (root) | The class's v1.0 specification — authoritative requirements; the seven types as written. +| `COMMENTARY.adoc` (root) | Type-theoretic foundations of the seven types (linear logic, session types, refinement types, …). +| *`docs/design/0001-v2-constitution.adoc`* | **Start here.** The binding contract for v2.0: the data model, the physical-safety judgement (§5), the AST shape, the proof obligations, the repo layout. +| `docs/spec/0001-lexical-grammar.adoc` | Normative lexical grammar (closes grammar.ebnf O3): tokens, dimensioned literals, the unit suffix table. +| `docs/spec/0002-concrete-grammar.adoc` | The complete EBNF v2 (closes O1/O2; adds material/tool/appliance/recipe/use/bind). +| `docs/spec/0003-type-system.adoc` | The normative type system: inference rules, the physical-safety judgement, linearity (QTT), error vocabulary, soundness statement. +| `docs/design/0002-semantics.adoc` | Operational + denotational semantics and the safety calculus (theorem statements + discharge strategy; progress/preservation). +| `docs/design/0003-hal-and-hardware.adoc` | The HAL: abstract capability trace, Matter/MQTT/ROS 2 projections, the trust boundary, device certification for *future* hardware. +| `docs/design/0004-stdlib-ontology.adoc` | The material/tool/appliance ontology with real numbers; four worked recipes and the one-line break that makes each ill-typed. +| `proofs/agda/` | `Dough`, `PoachedEgg`, `EchoBridge` (re-homed) + `NoCurdle` (v2.0 flagship, machine-checked material-envelope proof). +| `src/compiler/` | The OCaml reference front end + physical-safety checker + HAL (`ksc`). Dependency-free; `dune build`. +| `examples/v2/` | Well-typed recipes; `examples/v2/negative/` the corpus that must be rejected; `tests/run-corpus.sh` asserts both. +|=== + +A 30-minute orientation: constitution → `examples/v2/poached-egg.ks` and +`scald-milk.ks` → run `bash tests/run-corpus.sh` → `docs/spec/0003-type-system.adoc` +§physical-safety → `proofs/agda/NoCurdle.agda`. + +== The golden thread: one property, end to end + +Follow *no-curdle* (milk must never reach its 82C curdle envelope) through every +layer of the stack — this is the spine the whole design hangs on: + +[cols="1,2,3", options="header"] +|=== +| Hop | Where | What happens to `Hob.Heat(m, target: 95C)` + +| Surface syntax | `examples/v2/negative/curdle-milk.ks` | The author writes a step heating milk to 95C. +| Lexer | `docs/spec/0001` · `lexer.ml` | `95C` lexes to one `QTY` token; `units.ml` normalises it to `{95, Temperature}`. +| Grammar / AST | `docs/spec/0002` · `parser.ml`, `ast.ml` | Parses to a `step` with target `m` and named arg `target: 95C`. +| Type rule | `docs/spec/0003` §physical-safety | The safety judgement requires the requested exposure strictly below every hard limit of each touched material. +| Safety judgement | `check.ml` `check_targets_exposure` | `m : Milk`; hard limit `curdle = 82C`; `95 ≥ 82` ⇒ emit **CURDLE**. +| Operational semantics | `docs/design/0002` | A well-typed configuration never steps into a *ruin phase* (CURDLED); the ill-typed program is rejected before it can. +| Proof | `proofs/agda/NoCurdle.agda` | `no-curdle : ∀ t → temp-at t < 82`; `stays-fresh : ∀ t → milk-state-at t ≡ FRESH`. Machine-checked. +| HAL | `docs/design/0003` §4 · `hal.ml` | `ksc lower` runs the checker first and *refuses to lower* the unsafe recipe — the trust boundary. +|=== + +The well-typed sibling (`scald-milk.ks`, `target: 70C`) passes every hop, emits an +`APPROACHING_SCALD` *warning* (soft threshold), and lowers cleanly to a Matter / +MQTT / ROS 2 trace. + +== Status (2026-06-15) + +* *Design*: constitution + 5 of 6 elaborated docs landed (HAL & ontology authored + by hand after a mid-run interruption; lexical/grammar/type-system/semantics from + the blueprint workflow). +* *Proofs*: `NoCurdle.agda` machine-checked (exit 0, zero unsolved goals) alongside + the re-homed `Dough`/`PoachedEgg`/`EchoBridge`. +* *Prototype*: `ksc` builds clean and passes the full 14-case corpus (5 accept, + 9 reject-with-expected-code) plus HAL lowering to all three backends. +* *Not yet*: the seven types' full session-typing/effect machinery in the checker + (the prototype enforces linearity, the three mandatory constructs, echo witnesses, + and the physical-safety keystone; choreographic deadlock-freedom and ceremony + commutation are specified but not yet implemented). Tracked in `ROADMAP.adoc`. diff --git a/docs/design/0001-v2-constitution.adoc b/docs/design/0001-v2-constitution.adoc new file mode 100644 index 0000000..d2066b7 --- /dev/null +++ b/docs/design/0001-v2-constitution.adoc @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — Design Constitution +:toc: +:toclevels: 3 +:icons: font + +[NOTE] +==== +This is the *binding contract* for the v2.0 elevation. The full blueprint +documents (`docs/spec/`, the other `docs/design/` notes) and the reference +compiler (`src/compiler/`) elaborate this constitution; they must not +contradict it. Where a blueprint document needs to refine a definition here, +it does so by *adding* detail, marked as such — never by silently diverging. + +It supersedes nothing in the class's `SPEC.adoc` v1.0: v1.0 remains the +authoritative teaching artefact. v2.0 is the *production elevation* of the +same language, and every well-typed v1.0 program is a well-typed v2.0 program +(with materials/appliances supplied by the standard library). +==== + +== 1. Thesis: physics in the types + +The v1.0 seven types are a *typing lesson*. v2.0 makes typing the **superpower**: +physical safety is enforced by the type system, so a program that could +*curdle the milk*, *scorch the egg*, *bake the spatula*, or *over-torque the +arm* **fails to compile**. + +This is the differentiator. Every other domestic-automation stack — Home +Assistant YAML, raw Matter clusters, recipe apps, generic robotics +middleware — orchestrates *untyped commands* and trusts the device firmware +to refuse the dangerous ones at run time. KitchenSpeak refuses them at +*compile* time, by carrying the physics of materials and the capability +envelopes of appliances *in the types*. + +.The leap, in one line per layer +[cols="1,3,3", options="header"] +|=== +| Layer | v1.0 (teaching) | v2.0 (production) + +| Primitive | SI units | Dimension-indexed quantities; the numeric carrier. +| Material | (implicit) | First-class type carrying a *safety envelope* (thermal/mechanical/chemical limits + phase typestate). +| Appliance | (implicit) | First-class type carrying a *capability envelope* (what it can do, and within which physical bounds). +| Tropical | bounded ramp | Bounded-derivative refinement over a trajectory, *checked against* material + appliance envelopes. +| Linear | use-once | Substructural use-once (QTT grades) with typestate transitions. +| Dyadic | tensor bind | Irreversible `⊗` with provenance: `Flour ⊗ Water → Dough`. +| Choreographic | session | Real-time multiparty session types; deadlock-freedom + deadline-meeting. +| Echo | oracle witness | The *proven/postulated* boundary becomes the *compile-time-proof / run-time-monitor* boundary. +| Ceremonial | ambient effect | Reader effect that re-paces a choreography; proven to commute with linearity. +|=== + +The slogan: **make illegal physical states unrepresentable.** + +== 2. The core data model + +Eight kinds of declaration, plus the mission body. Each is summarised here and +fully specified in `docs/spec/`. + +=== 2.1 Quantity & Dimension (`Primitive`) + +A *quantity* is a rational magnitude tagged with a *dimension* drawn from the +SI base set used by the kitchen: + +---- +Dimension ::= Temperature | Time | Mass | Torque | AngularRate + | Length | Viscosity | Power | Dimensionless + | (Dimension · Dimension) | (Dimension / Dimension) +---- + +Literals carry a unit suffix that fixes the dimension: `82C`, `5min`, `300g`, +`8Nm`, `80rpm`, `4cm`, `0.8Pa·s`, `2C/s`. Arithmetic preserves dimension; +comparing `82C` with `5min` is a *type error*, not a run-time surprise. +Internally every quantity is normalised to a base unit + a dimension vector +(units-of-measure, à la F#/Frink). + +=== 2.2 Material (the new keystone type) + +A *material* is a substance the kitchen acts on. Its type carries a **safety +envelope** — the physics that bounds what may be done to it without ruining it +or making it dangerous: + +---- +material Milk { + phase: LIQUID // current phase typestate + thermal { scald: 70C // soft limit (quality) + curdle: 82C // HARD limit (ruin) ← danger + freeze: 0C } +} +---- + +Each named thermal limit is classified as `soft` (a quality threshold, warned) +or `hard` (a ruin/hazard threshold, rejected). The *danger set* of a material +is its hard limits. A material may also carry `mechanical { … }` limits +(max shear, max compression) and `chemical { … }` limits (pH band, +incompatible-with set). Phase is a typestate; transitions +(`RAW → COOKED`, `LIQUID → SET`, `WHOLE → CURDLED`) are tracked, and the +*ruin* phases (`CURDLED`, `SCORCHED`, `BURNT`) are absorbing failure states +the checker forbids entering. + +=== 2.3 Tool + +A *tool* is an inert implement (not actively controlled) that nonetheless has +material limits — the spatula. Modelled as a material with a `material:` tag +and its own envelope: + +---- +tool Spatula { material: SILICONE thermal { melt: 260C char: 320C } } +---- + +"Bake the spatula" = exposing a tool to a temperature at or above its hard +limit. A compile error. + +=== 2.4 Appliance (the capability envelope) + +An *appliance* is an actively-controlled device. Its type lists *capabilities*, +each a verb plus the *envelope* within which the device can physically and +safely act: + +---- +appliance Hob { + capability Heat { quantity: Temperature range: 0C .. 250C slope: 0C/s .. 5C/s } +} +appliance Oven { capability Bake { quantity: Temperature range: 50C .. 300C } } +appliance Arm { // ROS 2 robot arm + capability Move { quantity: Torque range: 0Nm .. 8Nm reach: 0cm .. 85cm } + capability Whisk { quantity: AngularRate range: 0rpm .. 1200rpm } +} +---- + +An appliance's capability envelope is what the *device* can do; a material's +safety envelope is what the *substance* can take. A step is safe only inside +the **intersection** of the two. + +=== 2.5 Resource, Chef, Echo, Ceremony + +* *Resource* — a *linear instance* of a material brought into a mission + (`use Milk as m;`). Used exactly once; consumed or transformed by actions. +* *Chef* — a persona that *controls* one or more appliances with a *style* + envelope modifier (`chef Delia controls Hob style PRECISION;`). Style tightens + slopes/jitter; it can only *shrink* envelopes, never widen them. +* *Echo* — a postulated sensor oracle bound to a modality + (`echo @white_set on Hob.visual;`); witnesses a physical predicate. +* *Ceremony* — an ambient context (`ceremony Sunday { pace: SLOW; … }`) that + re-paces a mission; provably cannot duplicate or discard resources. + +== 3. The mission body & the step + +---- +recipe [under ] { + * // mission-scoped resources, binds + * +} + +stmt ::= sync_block | bind_step | use_decl | resource_decl +sync ::= sync "(" actor (, actor)* ")" [ "proving" "@"echo ] "{" step+ "}" +bind ::= "(" res (, res)+ ")" "<~>" actor "->" res ";" // dyadic +step ::= actor "." Verb "(" target [, arg]* ")" + "until" condition + "max_duration" qty + "on_fail" ( ABORT | RECOVER | WARM ) ";" +arg ::= ident ":" ( qty | slope_tag | ident ) +cond ::= "~" metric cmp qty // tropical threshold + | "@" echo cmp value // echo witness +---- + +The three production-mandatory constructs (`until`, `max_duration`, `on_fail`) +are *non-optional* — a step missing any is a type error. They make every +`sync_block` a total function into `{success} ⊎ {aborted, recovered, warmed}`. + +== 4. The typed AST (binding shape for `src/compiler/`) + +The OCaml AST mirrors this constitution one-to-one. Authoritative module: +`src/compiler/ast.ml`. Shape (abbreviated): + +---- +type dim = Temperature | Time | Mass | Torque | AngularRate + | Length | Viscosity | Power | Dimensionless | Mul of dim*dim | Div of dim*dim +type qty = { mag : float; dim : dim } (* magnitude in base unit *) +type limit_kind = Soft | Hard +type limit = { name : string; kind : limit_kind; value : qty } +type envelope = { thermal : limit list; mechanical : limit list; chemical : limit list } +type material = { mname : string; phase : string; env : envelope; tool_of : string option } +type capability = { verb : string; quantity : dim; lo : qty; hi : qty; + slope : (qty*qty) option; reach : (qty*qty) option } +type appliance = { aname : string; caps : capability list } +type step = { actor:string; verb:string; target:string; args:(string*arg) list; + cond:cond; max_dur:qty; on_fail:fail } +... (sync_block, bind_step, recipe, program) +---- + +== 5. The physical-safety judgment (the keystone rule) + +A step `actor.Verb(target, args…)` is **physically safe** iff, letting +`cap = capability(appliance(actor), Verb)` and `req = requested envelope from args`: + +[stem] +++++ +\frac{ + \text{cap exists} \quad + \text{req} \subseteq \text{cap (device envelope, after style)} \quad + \forall m \in \text{materials-touched}(target).\ \text{req} \prec \text{danger}(m) +}{ + \Gamma \vdash \texttt{actor.Verb(target, args)} : \mathsf{Safe} +} +++++ + +Read operationally, the checker proves three things for every step: + +1. *Capability* — the controlling appliance actually has verb `Verb`. +2. *Device envelope* — every requested quantity (target temp, slope, torque, + rpm, reach) lies inside the appliance's capability range *after* the chef + style modifier has been applied. (Style may only shrink the range.) +3. *Material safety* — for every material and tool the step touches, the + requested exposure is strictly below every *hard* limit relevant to the + verb's quantity. At/above a hard limit ⇒ rejected with a named diagnostic + (`CURDLE`, `SCORCH`, `MELT_TOOL`, `OVER_TORQUE`, `OVER_REACH`). Below a + *soft* limit but above the quality threshold ⇒ a *warning*, not an error. + +Plus the structural obligations: + +* *Linearity* — each resource is consumed exactly once (use-once); an unused + resource at recipe end is a *leak* error; a twice-used resource is a + *double-use* error. +* *Termination* — every step carries `max_duration` (well-founded measure). +* *Totality* — every step carries `on_fail` (the failure branch is inhabited). +* *Witness* — a `sync … proving @e` block requires `@e` to be a declared echo + whose modality the controlling appliance offers. + +== 6. HAL projection & the trust boundary + +A well-typed program lowers to an abstract *capability trace*: a sequence of +`(actor, verb, envelope, until, max_dur, on_fail, witness?)` records. Backends +project the abstract trace to a concrete wire protocol: + +[cols="1,3", options="header"] +|=== +| Backend | Projection +| Matter | capability → Matter cluster/attribute writes (e.g. Heat → Temperature Control cluster). +| MQTT | capability → topic + JSON payload on a local broker (Zero Scams: no cloud). +| ROS 2 | capability → action goal on a `control_msgs` action server (arm Move/Whisk). +|=== + +The **proven/postulated boundary becomes a run-time trust boundary**: the +compile-time safety proof holds *given* the echo postulates and the GENTLE/style +controller contracts. At lowering, each echo postulate must be discharged by a +*local* sensor binding (Zero Scams — §6 SPEC) and each controller contract by a +device whose certified envelope the appliance type already captured. If a target +device cannot supply a required local sensor or cannot honour an envelope, the +program **does not lower to that device** — the type that made it safe is the +same type that gates deployment. + +== 7. "Prove the stuff" — the proof obligations + +Each safety property has a corresponding machine-checked theorem, lowered to +Agda now (Lean 4 reserved, ADR 0001): + +[cols="1,3", options="header"] +|=== +| Property | Theorem (recipe-level) +| No-curdle / no-scorch | the bounded controller trajectory never reaches a material's hard thermal limit (generalises `PoachedEgg.tropical-safe`). +| No-bake-tool | a tool's exposure never reaches its melt/char limit. +| No-over-torque | the requested torque/rate never exceeds the appliance envelope. +| Linearity | each resource appears in no output type and is threaded once (QTT-Agda port). +| Termination + totality | every loop is structurally recursive on its `max_duration` fuel; every result is a total case-split. +| Echo witness | every success carries an existential sensor witness. +|=== + +`proofs/agda/NoCurdle.agda` is the v2.0 flagship: the material-envelope analogue +of `PoachedEgg.agda`, proving a `ScaldMilk` recipe keeps milk strictly below its +curdle envelope. + +== 8. Repository layout for the elevation + +---- +docs/spec/ lexical grammar, concrete grammar v2, type-system rules (normative) +docs/design/ this constitution + semantics, HAL, stdlib design notes +src/compiler/ OCaml reference front end + checker (dependency-free; dune) + lexer.ml parser.ml ast.ml types.ml env.ml stdlib.ml check.ml hal.ml main.ml +examples/v2/ well-typed recipes (scald-milk, poached-egg, sear, mayo, plate) +examples/v2/negative/ recipes that MUST be rejected, each annotated with its error +proofs/agda/ Dough, PoachedEgg, EchoBridge (re-homed) + NoCurdle (new) +tests/ accept/reject test runner over the corpus +---- + +== 9. Non-negotiables (inherited from v1.0, kept in v2.0) + +* *Zero Scams* — safety-critical echoes resolve against *local* sensors only. +* *Hardware-agnostic* — one recipe lowers to Matter, MQTT, and ROS 2 alike. +* *Agda-Proven* — the core recipe library type-checks with zero unsolved goals. +* *Backwards-compatible* — every well-typed v1.0 program remains well-typed. diff --git a/docs/design/0002-semantics.adoc b/docs/design/0002-semantics.adoc new file mode 100644 index 0000000..4bb932b --- /dev/null +++ b/docs/design/0002-semantics.adoc @@ -0,0 +1,909 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — Operational Semantics & the Safety Calculus +:toc: +:toclevels: 3 +:icons: font +:stem: latexmath + +[NOTE] +==== +This document elaborates the v2.0 Design Constitution +(`docs/design/0001-v2-constitution.adoc`) along its *dynamic* axis: what a +well-typed recipe _does_ when it runs, and exactly which of the constitution's +safety promises are kept by the type checker (compile time) versus by an echo +monitor (run time). It is binding-subordinate: where the constitution fixes a +shape (the keystone judgment §5, the three mandatory constructs §3, the +proven/postulated boundary §6), this document refines it and must not +contradict it. + +It reuses, without re-deriving, the static keystone judgment +stem:[\Gamma \vdash s : \mathsf{Safe}] from constitution §5. That judgment is +the _door_; this document is the _room_: it says what stepping through a +well-typed door looks like, and proves that you never fall through the floor. + +Backwards-compatibility (constitution §9): every reduction rule degrades to the +v1.0 reading when materials and appliances are the standard-library defaults, so +every well-typed v1.0 program retains its v1.0 behaviour. +==== + +== 1. Configurations + +A running recipe is a small-step rewrite over a six-component *configuration*. +Where the constitution speaks of "resource store + appliance state + wall-clock ++ sensor/echo environment + emitted device-trace", we name and type each +component. + +[stem] +++++ +\kappa \;=\; \langle\, \Sigma,\; \Delta,\; \tau,\; \mathcal{E},\; \Phi,\; \Theta \,\rangle +++++ + +[cols="1,1,4", options="header"] +|=== +| Symbol | Name | Contents + +| stem:[\Sigma] +| Resource store +| A linear multiset of live resources, each `r ↦ (Material, phase, grades)`. + `grades` is the QTT residue (constitution §2.5): stem:[1] = exactly-once-live, + stem:[0] = consumed/erased, stem:[\omega] = unrestricted (ambient appliance + handles only). The store is *affine-by-construction*: a resource leaves + stem:[\Sigma] only by being consumed or transformed, never copied. + +| stem:[\Delta] +| Appliance state +| For each appliance, its current capability lease and a typestate: + `IDLE`, `ACTIVE(verb, env)`, or `FAULTED`. The lease records the + post-style envelope the chef holds (constitution §2.5: style may only + _shrink_). + +| stem:[\tau] +| Wall-clock +| A monotone nondecreasing rational in seconds. Only the reduction relation + advances it; the program cannot read or write it except through `until` / + `max_duration`. + +| stem:[\mathcal{E}] +| Echo environment +| The postulated sensor oracles bound by `echo @e on A.modality`. Modelled as + a sampling function per modality, stem:[\mathcal{E}(e) : \mathsf{Time} \to V], + exactly the `temp-at` / `white-set-at` / `viscosity-at` of the Agda proofs. + stem:[\mathcal{E}] is *read-only* to the program and *opaque* to the type + checker — it is the trust boundary made concrete. + +| stem:[\Phi] +| Pace / ceremony reader +| The ambient ceremony context: `pace`, `alert_level`, `priority`, and the + derived *budget scale* stem:[\beta \in \mathbb{Q}_{>0}] (constitution §2.5; + COMMENTARY "Ceremonial"). stem:[\Phi] is a _reader_ effect — it is threaded, + never mutated by steps. + +| stem:[\Theta] +| Device trace +| The emitted, append-only *capability trace* (constitution §6): a sequence of + records stem:[(A, \mathit{verb}, \mathit{env}, \mathit{until}, \mathit{maxdur}, \mathit{onfail}, \mathit{witness?})]. + stem:[\Theta] is write-only and the denotational observable. +|=== + +=== 1.1 Phase tagging of configurations + +Every configuration carries a *phase tag* drawn from + +[stem] +++++ +\mathsf{Phase} \;=\; \{\, \mathsf{Run},\; \mathsf{Done}(o),\; \mathsf{Ruin}(d) \,\} +++++ + +where stem:[o \in \{\mathsf{success}, \mathsf{aborted}, \mathsf{recovered}, \mathsf{warmed}\}] +is a mission outcome (constitution §3), and stem:[d] ranges over the *ruin +diagnostics* `CURDLE | SCORCH | MELT_TOOL | OVER_TORQUE | OVER_REACH` +(constitution §5). The whole point of the calculus is the theorem in §6.2: + +[IMPORTANT] +==== +*A well-typed configuration never carries a stem:[\mathsf{Ruin}(d)] tag.* The +ruin phases are reachable only as syntactic objects in the metatheory, used to +_state_ the safety theorems; the operational semantics has no rule that produces +one from a well-typed source. They are the absorbing failure states of +constitution §2.2, present so we can prove they are unreachable. +==== + +The initial configuration of `recipe R under C` over inputs stem:[\bar{r}] is + +[stem] +++++ +\mathit{init}(R, C, \bar{r}) \;=\; \langle\, \Sigma_0[\bar{r}],\; \Delta_0\ (\text{all IDLE}),\; 0,\; \mathcal{E},\; \Phi_C,\; \varepsilon \,\rangle \;:\; \mathsf{Run} +++++ + +== 2. Small-step reduction + +We write stem:[\kappa \longrightarrow \kappa'] for one reduction step, and +stem:[\longrightarrow^{*}] for its reflexive-transitive closure. Rules are +grouped by the construct they reduce. Throughout, stem:[\mathit{env}(a)] is the +requested envelope extracted from a step's arguments (constitution §5), and +stem:[\mathsf{cap}(A, \mathit{verb})] is the post-style capability envelope held +in stem:[\Delta]. + +=== 2.1 The step as bounded recursion on fuel (the `until`-loop) + +A step is `A.Verb(target, args) until cond max_duration m on_fail h`. Its +dynamics are a *fuel-bounded feedback loop*, mirroring `heat` / `poach` / +`knead` in the Agda proofs exactly. Fuel is the budget measured in sampling +ticks, scaled by the ceremony: + +[stem] +++++ +\mathit{fuel}_0 \;=\; \left\lfloor \beta \cdot \dfrac{m}{\delta} \right\rfloor +\qquad (\delta = \text{tick length},\ \beta = \Phi.\mathit{budgetScale}) +++++ + +There are exactly four step rules — one entry, two loop transitions, one +exhaustion — and they are *jointly exhaustive on the configuration*, which is +where progress comes from (§6.1). + +.S-Enter — open the lease, emit the trace record +[stem] +++++ +\frac{ + \Gamma \vdash s : \mathsf{Safe} \qquad \Delta(A) = \mathsf{IDLE} +}{ + \langle \Sigma, \Delta, \tau, \mathcal{E}, \Phi, \Theta \rangle\ \blacktriangleright\ s + \;\longrightarrow\; + \langle \Sigma, \Delta[A \mapsto \mathsf{ACTIVE}(\mathit{verb}, \mathit{env}(s))], \tau, \mathcal{E}, \Phi, \Theta \cdot \rho(s) \rangle\ \blacktriangleright\ \mathsf{loop}(s, \mathit{fuel}_0) +} +++++ + +stem:[\rho(s)] is the abstract trace record of constitution §6. The premise +stem:[\Gamma \vdash s : \mathsf{Safe}] is the keystone — *no step enters its +loop unless it is statically safe*. This is the single hinge on which §6.2 +turns. + +.S-Sat — the `until` condition is met: exit with witness +[stem] +++++ +\frac{ + \mathit{sat}(\mathit{cond}, \mathcal{E}, \tau) = \mathsf{tt} \qquad + w = \mathit{mkWitness}(\mathit{cond}, \tau) +}{ + \kappa\ \blacktriangleright\ \mathsf{loop}(s, \mathit{suc}\ n) + \;\longrightarrow\; + \kappa[\Delta(A)\!\mapsto\!\mathsf{IDLE}]\ \blacktriangleright\ \mathsf{ok}(s, w) +} +++++ + +The two condition forms (`grammar.ebnf`) discharge stem:[\mathit{sat}] +differently: + +* *Tropical threshold* `~ metric cmp q`: stem:[\mathit{sat}] reads the + controller-projected metric and compares; the witness stem:[w] is a + _trajectory-bound_ witness, the operational image of `tropical-safe`. +* *Echo witness* `@e cmp v`: stem:[\mathit{sat}] reads stem:[\mathcal{E}(e)(\tau)]; + the witness stem:[w = (\tau, \mathit{pf})] is the *dependent pair* of sample + time and threshold proof — literally `ShimmerWitness` / `WhiteWitness` / + `Witness`, and via `EchoBridge.agda` an `echo-types` `Echo (fired e thr) true`. + +.S-Step — not yet satisfied, fuel remains: sample, tick, recurse +[stem] +++++ +\frac{ + \mathit{sat}(\mathit{cond}, \mathcal{E}, \tau) = \mathsf{ff} \qquad + \mathit{env}(s) \subseteq \mathsf{cap}(A,\mathit{verb}) \qquad + \forall m \in \mathsf{touched}(s).\ \mathit{env}(s) \prec \mathsf{danger}(m) +}{ + \langle \Sigma, \Delta, \tau, \mathcal{E}, \Phi, \Theta \rangle\ \blacktriangleright\ \mathsf{loop}(s, \mathit{suc}\ n) + \;\longrightarrow\; + \langle \Sigma, \Delta, \tau + \delta, \mathcal{E}, \Phi, \Theta \rangle\ \blacktriangleright\ \mathsf{loop}(s, n) +} +++++ + +[IMPORTANT] +==== +The two premises on the right of *S-Step* are the *operational re-assertion* of +the static keystone judgment, evaluated against the _live_ envelope each tick. +On a well-typed source they are _redundant_ — preservation (§6.3) guarantees +they hold — but stating them makes the no-ruin theorem self-evident: there is +*no rule* that advances stem:[\tau] while a danger limit is reached, so no +trajectory ever crosses one. This is curdle/scorch/melt/over-torque safety _as a +property of the reduction relation_, independent of the proof in Agda. +==== + +.S-Exhaust — fuel zero: take the `on_fail` branch +[stem] +++++ +\frac{}{ + \kappa\ \blacktriangleright\ \mathsf{loop}(s, \mathsf{zero}) + \;\longrightarrow\; + \kappa[\Delta(A)\!\mapsto\!\mathsf{IDLE}]\ \blacktriangleright\ \mathsf{fail}(h) +} +++++ + +stem:[\mathit{fuel}] is the *well-founded measure* (§6.4). S-Step strictly +decreases it (stem:[\mathit{suc}\ n \to n]); S-Sat and S-Exhaust leave the loop. +Hence every loop halts in at most stem:[\mathit{fuel}_0] steps — the operational +form of "structurally recursive on fuel" from `Dough.agda` §5. + +=== 2.2 `on_fail` transitions (ABORT / RECOVER / WARM) + +stem:[\mathsf{fail}(h)] resolves the mandatory handler. All three branches are +*inhabited and total* (constitution §3; COMMENTARY "on_fail"): this is why no +configuration is stuck at a failure point. + +.S-Abort +[stem] +++++ +\kappa\ \blacktriangleright\ \mathsf{fail}(\mathtt{ABORT}) +\;\longrightarrow\; +\langle \Sigma \setminus \mathsf{touched}(s),\ \Delta,\ \tau,\ \mathcal{E},\ \Phi,\ \Theta\cdot\overline{\mathtt{abort}}\rangle : \mathsf{Done}(\mathsf{aborted}) +++++ + +ABORT consumes the touched linear resources (the constitution's "permanent +loss"; `poached-egg.ks` Step 1 note: a cold hob wastes nothing because nothing +was committed; a wasted egg is modelled as consumption). The mission ends. + +.S-Recover — re-arm with a bounded supervisor extension +[stem] +++++ +\frac{ + k = \mathit{recoverGrant}(\Phi) \qquad k > 0 +}{ + \kappa\ \blacktriangleright\ \mathsf{fail}(\mathtt{RECOVER}) + \;\longrightarrow\; + \kappa\ \blacktriangleright\ \mathsf{loop}(s, k) +} +++++ + +RECOVER re-enters the loop with a _fresh, strictly positive, bounded_ fuel grant +stem:[k] drawn from the ceremony's supervisor budget (`poached-egg.ks` Step 2: +"extend by 30s under a supervisor override"). Crucially stem:[k] is drawn from a +*finite* per-mission recovery budget stem:[K_{\max}], decremented on each grant; +when stem:[\mathit{recoverGrant}] returns stem:[0] the next rule fires: + +.S-Recover-Exhausted +[stem] +++++ +\frac{ \mathit{recoverGrant}(\Phi) = 0 }{ + \kappa\ \blacktriangleright\ \mathsf{fail}(\mathtt{RECOVER}) + \;\longrightarrow\; + \langle \Sigma \setminus \mathsf{touched}(s),\ \Delta,\ \tau,\ \mathcal{E},\ \Phi,\ \Theta\cdot\overline{\mathtt{recovered}}\rangle : \mathsf{Done}(\mathsf{recovered}) +} +++++ + +The finiteness of stem:[K_{\max}] is what keeps RECOVER from defeating +termination: total fuel consumed by a step is bounded by +stem:[\mathit{fuel}_0 + K_{\max}] (§6.4). + +.S-Warm — hold safe, release resources non-destructively +[stem] +++++ +\kappa\ \blacktriangleright\ \mathsf{fail}(\mathtt{WARM}) +\;\longrightarrow\; +\langle \Sigma',\ \Delta[A\mapsto\mathsf{ACTIVE}(\mathit{hold},\,\mathit{env}_{\mathsf{warm}})],\ \tau,\ \mathcal{E},\ \Phi,\ \Theta\cdot\overline{\mathtt{warm}}\rangle : \mathsf{Done}(\mathsf{warmed}) +++++ + +WARM transitions the appliance to a *hold* envelope stem:[\mathit{env}_{\mathsf{warm}}] +that is, by the WARM well-formedness side-condition, _strictly inside every +touched material's danger set_ — the held state is itself a safe step. The +touched resources survive in stem:[\Sigma'] in a `HELD` phase (not consumed): +WARM is the one outcome that does not destroy the linear resource, which is why +it is the right handler for "keep dinner edible while the guests are late". + +=== 2.3 Sync barriers + +`sync(A₁,…,Aₙ) proving @e { step⁺ }` is a multiparty session barrier +(COMMENTARY "Choreographic"). Its dynamics: all listed actors must reach the +barrier, the body runs, and the barrier releases only on the witness. + +.S-Sync-Enter — all actors idle, install the barrier +[stem] +++++ +\frac{ + \forall i.\ \Delta(A_i) = \mathsf{IDLE} \qquad + \texttt{@}e \text{ declared on some } A_i.\mathit{modality} \quad(\text{constitution §5 witness}) +}{ + \kappa\ \blacktriangleright\ \mathsf{sync}(\bar{A}, e, \bar{s}) + \;\longrightarrow\; + \kappa\ \blacktriangleright\ \mathsf{bar}(\bar{A}, e,\ \mathsf{seq}(\bar{s})) +} +++++ + +.S-Sync-Body — run the body to a result (sequential per O1; see §2.6) +[stem] +++++ +\frac{ + \kappa\ \blacktriangleright\ \mathsf{seq}(\bar{s}) \;\longrightarrow^{*}\; \kappa'\ \blacktriangleright\ o +}{ + \kappa\ \blacktriangleright\ \mathsf{bar}(\bar{A}, e, \mathsf{seq}(\bar{s})) + \;\longrightarrow\; + \kappa'\ \blacktriangleright\ \mathsf{release}(\bar{A}, e, o) +} +++++ + +.S-Sync-Release — discharge the barrier-release witness +[stem] +++++ +\frac{ + o = \mathsf{success} \;\Rightarrow\; \exists t \le \tau'.\ \mathcal{E}(e)(t)\ \text{fired} +}{ + \kappa'\ \blacktriangleright\ \mathsf{release}(\bar{A}, e, o) + \;\longrightarrow\; + \langle \ldots, \Theta'\cdot \mathit{witness}(e, t) \rangle\ \blacktriangleright\ o +} +++++ + +The premise is the COMMENTARY "proving" reading: the block is a _named +existential witness binder_ — success entails stem:[\exists t.\ @e(t)]. The +witness is appended to the trace, which is what makes the echo-witness theorem +(§5.6, `served-gives-witnesses`) hold at the recipe level. + +*Deadlock-freedom* is structural: stem:[\mathsf{sync}] holds appliance leases +only between S-Sync-Enter and the matching release, leases are acquired in the +single textual order of the (currently non-nestable, O1) body, and no step +acquires a lease it does not release. Hence no cyclic wait can form (§6.5). + +=== 2.4 Dyadic bind + +`(r₁,…,rₙ) <~> A -> r_out;` is the irreversible tensor of the constitution +(§2.4) and COMMENTARY "Dyadic". It is _algebra, not physics_ (`grammar.ebnf` +Patch 2 slogan): it does not open an appliance lease or advance the clock. + +.S-Bind +[stem] +++++ +\frac{ + \forall i.\ \Sigma(r_i) = (\_,\_, 1) \qquad + \mathsf{compatible}(\overline{\mathsf{chem}(r_i)}) \qquad + M_{\mathit{out}} = \mathsf{combine}(\overline{\mathsf{Material}(r_i)}) +}{ + \langle \Sigma, \ldots \rangle\ \blacktriangleright\ (\bar{r}) \mathbin{<\!\sim\!>} A \to r_{\mathit{out}} + \;\longrightarrow\; + \langle \Sigma[\bar{r}\mapsto 0,\ r_{\mathit{out}}\mapsto (M_{\mathit{out}},\,\phi_{\mathit{out}},\,1)],\ \ldots \rangle\ \blacktriangleright\ \checkmark +} +++++ + +Each input grade goes stem:[1 \to 0] (consumed exactly once — the +`bind-dough` shape: "consumes both, returns neither"). The output carries the +*union of provenance typestate* (COMMENTARY "Dyadic" proof obligation) and the +*intersection-refined safety envelope* stem:[\phi_{\mathit{out}}]: a hard limit +of the product is the tightest hard limit of its factors, so the product can +never be _less_ safe than its most fragile input (this is what keeps +`Flour ⊗ Water → Dough` from forgetting that an egg-enriched dough still +curdles). The `compatible` premise rejects chemically incompatible binds at the +static layer (constitution §2.2 `incompatible-with`); it appears here only as +the preserved invariant. + +=== 2.5 Ceremony re-pacing + +A ceremony is a *reader* over the whole reduction (COMMENTARY "Ceremonial": +commutes with linearity). It is installed once and never mutated: + +.S-Ceremony +[stem] +++++ +\frac{ + \Phi' = \Phi_C \qquad \beta = \mathit{pace}(C) \in \mathbb{Q}_{>0} +}{ + \langle \Sigma, \Delta, \tau, \mathcal{E}, \Phi, \Theta \rangle\ \blacktriangleright\ (\mathbf{under}\ C\ \{\bar{b}\}) + \;\longrightarrow\; + \langle \Sigma, \Delta, \tau, \mathcal{E}, \Phi', \Theta \rangle\ \blacktriangleright\ \mathsf{seq}(\bar{b}) +} +++++ + +The re-pacing acts *only* through stem:[\beta] in the fuel formula (§2.1) and +through stem:[\mathit{recoverGrant}] / stem:[\mathit{env}_{\mathsf{warm}}]. +Decisively, stem:[\Phi] *does not appear in stem:[\Sigma]* in any rule: no rule +reads stem:[\Phi] to add, copy, or drop a resource. This is the operational +form of the COMMENTARY obligation "ceremony effects commute with linear-resource +effects": + +[stem] +++++ +\Sigma\text{-projection}(\kappa \xrightarrow{\ C\ } \kappa') \;=\; \Sigma\text{-projection}(\kappa \xrightarrow{\ C'\ } \kappa') +\quad\text{for any two ceremonies } C, C'. +++++ + +A `PRECISION`/`SLOW` ceremony may shrink stem:[\beta] (tighter budget, +lower jitter) but can never widen an envelope — *re-pacing only shrinks*, +matching the constitution's "style may only shrink" rule (§2.5). + +=== 2.6 What is _not_ reduced here (open items) + +Per `grammar.ebnf` O1, `sync` bodies are non-nestable: S-Sync-Body reduces a +flat `seq` of steps. The semantics is therefore stated for the *flat* fragment; +nesting (O1) and the undefined `resource_decl`/`chef_decl`/`echo_decl` +productions (O2) are deferred exactly as the grammar defers them. When O1 is +closed, S-Sync-Body generalises to reduce a nested barrier, and deadlock-freedom +(§6.5) must be re-proved over the nesting tree rather than the textual line. + +== 3. Denotational sketch + +A recipe denotes a *total* function from its inputs to an outcome-tagged result +paired with the emitted trace — the constitution's +stem:[\mathsf{Inputs} \to (\{\mathsf{success}\} \uplus \{\mathsf{aborted}, \mathsf{recovered}, \mathsf{warmed}\}) \times \mathsf{Trace}]. + +[stem] +++++ +\mathcal{D}\llbracket R \rrbracket \;:\; \mathsf{Inputs}_R \times \mathcal{E}\text{-env} \times \mathsf{Ceremony} \;\longrightarrow\; \mathsf{Outcome} \times \mathsf{Trace} +++++ + +where + +[stem] +++++ +\mathsf{Outcome} \;=\; \underbrace{\mathsf{success}}_{\text{carries }\exists\text{-witnesses}} \;\uplus\; \mathsf{aborted} \;\uplus\; \mathsf{recovered} \;\uplus\; \mathsf{warmed} +++++ + +The denotation is read off the operational semantics by the *adequacy* equation + +[stem] +++++ +\mathcal{D}\llbracket R \rrbracket(\bar{r}, \mathcal{E}, C) = (o, \Theta') +\quad\Longleftrightarrow\quad +\mathit{init}(R, C, \bar{r}) \;\longrightarrow^{*}\; \langle \ldots, \Theta' \rangle : \mathsf{Done}(o). +++++ + +This is _exactly_ the type of `poached-egg : Water COLD → Egg RAW → Vinegar → +ProgramResult` in `PoachedEgg.agda` §7, with `ProgramResult` = stem:[\mathsf{Outcome}] +(`Served`/`HeldWarm`/`ColdAbort`) and the witnesses inside `Served`/`HeldWarm` +being the trace's stem:[\exists]-content. Three properties make +stem:[\mathcal{D}] a genuine _total_ function: + +. *Definedness everywhere (totality).* The right-hand side always exists because + the reduction always reaches a stem:[\mathsf{Done}] (§6.4 termination + §6.1 + progress). There is no stem:[\bot] in stem:[\mathsf{Outcome}]. +. *Single-valuedness (determinism modulo stem:[\mathcal{E}]).* Given a fixed + oracle environment stem:[\mathcal{E}], the reduction is deterministic: at each + configuration exactly one rule applies (the four step rules are mutually + exclusive on the loop tag; the bind/sync/ceremony rules on the redex shape). + Hence stem:[\mathcal{D}] is a function, not a relation. +. *Witnessed success.* The stem:[\mathsf{success}] summand is _not_ a bare tag: + it is stem:[\Sigma_{\bar{t}}.\ \bigwedge_e @e(t_e)], a tuple of echo witnesses + (`served-gives-witnesses`). Operationally these are the + stem:[\mathit{witness}(e,t)] records appended by S-Sync-Release. + +The trace stem:[\Theta'] is the *observable*: two recipes are denotationally +equal iff they yield the same outcome and the same capability trace on every +input/oracle/ceremony triple. This makes the HAL projection of constitution §6 a +denotation-preserving _post-composition_ stem:[\pi_{\mathsf{backend}} \circ \mathcal{D}\llbracket R \rrbracket]. + +== 4. The safety calculus: theorems and their discharge + +This is the heart of the document. For each safety property the constitution +promises (§§5, 7), we state the theorem precisely, classify _how_ it is +discharged (compile-time refinement check vs run-time echo monitor), locate the +proven/postulated boundary, and name its Agda lowering. + +=== 4.1 Discharge taxonomy + +[cols="1,3", options="header"] +|=== +| Discharge class | Meaning + +| *CT-refine* +| Discharged entirely at compile time by the keystone refinement checker + (constitution §5). No run-time component. If it does not hold, the program + does not type-check. + +| *CT-struct* +| Discharged at compile time by a structural/substructural check (linearity, + presence of `max_duration`/`on_fail`, projection compatibility). Syntactic, + not numeric. + +| *RT-monitor* +| Discharged at run time by an echo monitor against a _local_ sensor + (constitution §9 Zero Scams). The compile-time proof holds _given_ the + postulate; the monitor is what makes the postulate honest on the device. + +| *Boundary* +| A theorem whose statement is CT but whose _soundness_ rests on a postulate + discharged RT. These are the proven/postulated seams. +|=== + +=== 4.2 The theorems + +[cols="2,4,1,3", options="header"] +|=== +| Property | Theorem (recipe-level) | Discharge | Agda lowering + +| *No-curdle / no-scorch* +| For every reachable configuration stem:[\kappa] and every material + stem:[m \in \mathsf{touched}], the live exposure is _strictly_ below every + hard thermal limit: stem:[\forall \kappa\ \text{reachable}.\ \mathit{env}(\kappa) \prec \mathsf{danger}_{\mathit{thermal}}(m)]. Equivalently: no reachable stem:[\kappa] is stem:[\mathsf{Ruin}(\mathtt{CURDLE})] or stem:[\mathsf{Ruin}(\mathtt{SCORCH})]. +| *CT-refine* + *Boundary* +| `PoachedEgg.tropical-safe` (proven: trajectory stem:[< 100\mathrm{C}]); the + flagship generalisation is `NoCurdle.agda` (planned), the material-envelope + analogue proving `ScaldMilk` keeps milk stem:[< 82\mathrm{C}] curdle. + +| *No-bake-tool* +| For every tool stem:[t \in \mathsf{touched}] and reachable stem:[\kappa], + exposure stem:[\prec] the tool's `melt`/`char` hard limit; no reachable + stem:[\mathsf{Ruin}(\mathtt{MELT\_TOOL})]. +| *CT-refine* +| `NoCurdle.agda` reuses the same envelope lemma over a `tool`-tagged material + (constitution §2.3: a tool _is_ a material with envelope). + +| *No-over-torque / over-reach* +| Requested torque/rate/reach stem:[\subseteq] the appliance's post-style + capability envelope at every reachable stem:[\kappa]; no reachable + stem:[\mathsf{Ruin}(\mathtt{OVER\_TORQUE})] or stem:[\mathsf{Ruin}(\mathtt{OVER\_REACH})]. +| *CT-refine* +| Direct from the keystone premise stem:[\mathit{env} \subseteq \mathsf{cap}]; + lowered as the device-envelope clause of the `NoCurdle.agda` step lemma (the + arm `Move`/`Whisk` capability of constitution §2.4). + +| *Linearity* +| Each resource is threaded exactly once: it appears in no output type and its + grade transitions stem:[1 \to 0] on consumption; an unused stem:[1]-grade at + recipe end is a _leak_ error, a second use a _double-use_ error. +| *CT-struct* +| `Dough.agda` §3 / `PoachedEgg.agda` linearity note: holds by signature + inspection now; *machine-checked under the planned QTT-Agda port* (grades + `@0/@1/@ω`). + +| *Termination* +| stem:[\longrightarrow^{*}] from any well-typed stem:[\mathit{init}] reaches a + stem:[\mathsf{Done}] in finitely many steps; the per-step measure + stem:[(\mathit{fuel} + \text{remaining recovery budget})] is well-founded. +| *CT-struct* +| Structural recursion of `heat`/`poach`/`knead` on `fuel`; the + `max_duration`-as-fuel argument of COMMENTARY "max_duration". + +| *Totality* +| stem:[\mathcal{D}\llbracket R \rrbracket] is total: every input/oracle/ceremony + triple maps to a defined stem:[(o, \Theta)]; every `on_fail` branch is + inhabited. +| *CT-struct* +| `heat-exhaustive`/`poach-exhaustive`/`kr-exhaustive` (every result is a + total case-split); COMMENTARY "on_fail". + +| *Deadlock-freedom* +| No reachable configuration is _stuck in_ stem:[\mathsf{bar}]: every installed + barrier eventually releases or fails; appliance leases form no cyclic wait. +| *CT-struct* +| Session-projection compatibility (COMMENTARY "Choreographic"). Lemma + `sync-progress` (planned in `NoCurdle.agda` / a `Choreography.agda`): each + local projection well-typed ⇒ global barrier releases. + +| *Echo-witness* +| Every stem:[\mathsf{success}] outcome carries an existential sensor witness + stem:[\exists t.\ @e(t)]; no success-without-proof. +| *RT-monitor* + *Boundary* +| `served-gives-witnesses` / `heldwarm-gives-witness` / `success-gives-witness`; + the witness _type_ is `EchoBridge.SensorEcho` = `echo-types` `Echo (fired e thr) true`. +|=== + +=== 4.3 The proven/postulated boundary, made precise + +The constitution (§6) says the proven/postulated boundary _becomes_ the +compile-time-proof / run-time-monitor boundary. The calculus pins down where the +seam runs, item by item, matching the Agda postulates exactly: + +[cols="3,2,4", options="header"] +|=== +| Postulate (the trusted base) | In Agda | Discharged at run time by + +| *Sensor soundness* — if classifier stem:[@e] emits "fired", the world really + satisfies the predicate. +| `temp-at`, `white-set-at`, `viscosity-at` (`postulate`) +| The *RT echo monitor* bound to a _local_ sensor (Zero Scams, constitution §9). + If the device cannot supply the local sensor, the program *does not lower* + to it (constitution §6). + +| *Controller contract* — under `GENTLE`/style, the emitted trajectory never + exceeds the target cap. +| `gentle-bounded : ∀ t. temp-at t ≤ target-cap` (`postulate`) +| A *certified device envelope*. The appliance type already captured it; at + lowering the device must honour it or the program does not deploy + (constitution §6). A production port _proves_ `gentle-bounded` from an + integrator bound on the slope (`PoachedEgg.agda` §9(b)). + +| *Dimensional fidelity* — the numeric carrier carries true SI content. +| stem:[\mathbb{N}] stand-ins for `Celsius`/`Minutes`/`Torque-dNm` +| Substituting dimension-indexed stem:[\mathbb{Q}] at the carrier + (`PoachedEgg.agda` §9(d)); a _refactor_, not a run-time check. + +| *Linearity as a type discipline* +| Currently by signature inspection (`Dough.agda` §3 note) +| The *QTT-Agda port* moves this from postulate-by-inspection to + machine-checked stem:[1]-grade tracking — turning a *Boundary* item into a + *CT-struct* one. +|=== + +Everything _above_ the seam (the keystone refinement, linearity, termination, +totality, deadlock-freedom, the witness _plumbing_) is *proven*. Everything +_on_ the seam (sensor soundness, controller contract) is *postulated at compile +time and monitored against local hardware at run time*. The calculus's +contribution is the no-ruin theorem (§6.2), which shows the proven layer is +strong enough that the only way to reach a ruin phase is for a postulate to be +_false_ — i.e. for a local sensor to lie or a certified controller to violate +its envelope. The type system has discharged everything it possibly can; the +residual trust is exactly the four rows above, and no larger. + +== 5. Metatheory + +We adapt progress and preservation to the configuration setting. Together they +give the headline: *a well-typed configuration is not stuck and never steps into +a ruin phase.* + +=== 5.1 Well-typed configurations + +stem:[\vdash \kappa : \mathsf{ok}] holds when (i) every resource in +stem:[\Sigma] has a consistent grade and phase, (ii) every appliance lease in +stem:[\Delta] is inside its post-style capability envelope, (iii) the pending +redex, if a step, satisfies the keystone judgment +stem:[\Gamma \vdash s : \mathsf{Safe}], and (iv) the phase tag is +stem:[\mathsf{Run}] or stem:[\mathsf{Done}(o)] — *never* stem:[\mathsf{Ruin}(d)]. +Condition (iv) is the load-bearing invariant. + +=== 5.2 Progress + +[stem] +++++ +\textbf{Progress.}\quad \text{If } \vdash \kappa : \mathsf{ok} \text{ and } \kappa : \mathsf{Run},\ \text{then } \exists\,\kappa'.\ \kappa \longrightarrow \kappa'. +++++ + +_Proof shape._ Case on the pending redex. A step in `Enter` fires S-Enter (its +keystone premise is given by (iii)). A step in `loop(s, suc n)` fires S-Sat or +S-Step (the stem:[\mathit{sat}] decision is total — `_≥?_` in the proofs). +A step in `loop(s, zero)` fires S-Exhaust. `fail(h)` fires one of S-Abort / +S-Recover / S-Recover-Exhausted / S-Warm, all inhabited. `bind`/`sync`/ceremony +redexes fire their single rule. The four step rules are *jointly exhaustive on +the loop configuration* — this is the operational counterpart of +`heat-exhaustive`/`poach-exhaustive`: there is no fifth case to be stuck in. +Hence no non-`Done` well-typed configuration is stuck. ∎ + +=== 5.3 Preservation (with the no-ruin strengthening) + +[stem] +++++ +\textbf{Preservation.}\quad \text{If } \vdash \kappa : \mathsf{ok} \text{ and } \kappa \longrightarrow \kappa',\ \text{then } \vdash \kappa' : \mathsf{ok}. +++++ + +In particular, condition (iv) is preserved: *if stem:[\kappa] is not a ruin +phase, neither is stem:[\kappa']*. This is the metatheoretic form of +no-curdle/no-scorch/no-bake-tool/no-over-torque. + +_Proof shape, per rule._ + +* *S-Enter / S-Step* — the keystone premises (capability stem:[\subseteq], + danger stem:[\prec]) are exactly conditions (ii)/(iii) re-asserted on the + live envelope; they carry forward. No danger limit is reached, so the phase + stays stem:[\mathsf{Run}], not stem:[\mathsf{Ruin}]. *This is the step where a + ruin phase could be entered, and the keystone judgment forbids it.* +* *S-Sat / S-Sync-Release* — append a witness to stem:[\Theta] and idle the + lease; stem:[\Sigma] unchanged; phase to stem:[\mathsf{Done}(\mathsf{success})]. +* *S-Abort / S-Recover-Exhausted* — consume touched grades stem:[1\to 0] + (linearity preserved: consumed, not duplicated); phase to a non-ruin + stem:[\mathsf{Done}]. +* *S-Recover* — re-enters the loop with strictly positive bounded fuel; the + measure (§5.5) still decreases because the recovery budget strictly drops. +* *S-Warm* — installs a hold envelope that the WARM side-condition forces + stem:[\prec] every touched danger set, so (ii) holds and no ruin is entered. +* *S-Bind* — grades stem:[1\to 0] on inputs, stem:[1] on output; the output + envelope is the tightest of its factors, so (ii) is preserved or tightened. +* *S-Ceremony* — touches only stem:[\Phi]; stem:[\Sigma]/stem:[\Delta] + invariants are literally unchanged (the commutation property of §2.5). ∎ + +=== 5.4 The corollary that names the superpower + +[stem] +++++ +\textbf{Safety (no-ruin).}\quad \text{If } \vdash \mathit{init}(R, C, \bar{r}) : \mathsf{ok},\ \text{then no } \kappa \text{ with } \mathit{init} \longrightarrow^{*} \kappa \text{ is a } \mathsf{Ruin}(d). +++++ + +_Proof._ Induction on the length of stem:[\longrightarrow^{*}] using +Preservation; the base case is condition (iv) of stem:[\vdash \mathit{init} : \mathsf{ok}]. ∎ + +This is the constitution's slogan discharged: *illegal physical states are +unrepresentable* in any reachable configuration of a well-typed program. +Curdling, scorching, baking the spatula, and over-torquing the arm are not +run-time hazards the firmware must catch — they are configurations the reduction +relation _cannot construct_ from a well-typed start. + +=== 5.5 The well-founded measure for termination + +The termination measure is the lexicographic pair + +[stem] +++++ +\mu(\kappa) \;=\; \big(\, \#\{\text{pending redexes}\},\ \ \mathit{recoveryBudget} + \mathit{fuel} \,\big) \;\in\; \mathbb{N} \times \mathbb{N} +++++ + +ordered lexicographically (well-founded as a product of well-founded orders). +S-Step strictly decreases the second component (stem:[\mathit{suc}\,n \to n]); +S-Sat / S-Exhaust / S-Abort / S-Warm / S-Sync-Release decrease the first +(retire a redex); S-Recover decreases the recovery budget inside the second +component, which is _separately_ bounded by stem:[K_{\max}]; S-Bind / S-Ceremony +decrease the first. No rule increases stem:[\mu]. Hence +stem:[\longrightarrow] is well-founded and every run halts — the +configuration-level form of "structurally recursive on fuel" from the three Agda +loops, extended to account for bounded RECOVER. The total step count is bounded +by stem:[\sum_{\text{steps}} (\mathit{fuel}_0 + K_{\max})], a closed number +fixed by the source and the ceremony. + +=== 5.6 Deadlock-freedom as a progress refinement + +Deadlock-freedom is the specialisation of Progress to barrier configurations: +no well-typed stem:[\kappa : \mathsf{Run}] is stuck _inside_ stem:[\mathsf{bar}]. +Given session-projection compatibility (the CT-struct check), S-Sync-Body always +makes progress on its non-empty body (by Progress on the body), and +S-Sync-Release always fires (its only premise is the witness, which on the +non-success path is vacuous and on the success path is supplied by the echo +monitor). Because leases are acquired in the single textual order of the flat +body (O1) and released on exit, the wait-for graph over appliances is acyclic; +no two barriers can hold each other's leases. ∎ + +== 6. Agda lowering map + +Each theorem above lowers to a named Agda artefact. The table fixes the +correspondence so `proofs/agda/NoCurdle.agda` (the planned flagship) can be +built against an explicit checklist, and so the existing proofs are seen as +_instances_ of the general calculus. + +[cols="2,3,2", options="header"] +|=== +| Calculus object | Agda lowering (existing) | Agda lowering (planned `NoCurdle.agda`) + +| Step-as-fuel-loop (§2.1) +| `heat`, `poach`, `knead` +| `scald : Milk LIQUID → (fuel : ℕ) → (elapsed : Minutes) → ScaldResult`, + structurally recursive on `fuel`. + +| `on_fail` totality (§2.2) +| `heat-exhaustive`, `poach-exhaustive`, `kr-exhaustive` +| `scald-exhaustive : ∀ r → (Σ … r ≡ Scalded …) ⊎ (r ≡ Aborted)`. + +| No-curdle (§4.2, §5.4) +| `tropical-safe : ∀ t → temp-at t < boil-envelope` (the boil analogue) +| `no-curdle : ∀ t → temp-at t < curdle-envelope`, with + `curdle-envelope = 82` (constitution §2.2 Milk hard limit) and + `cap= 66C + max_duration 5min + on_fail WARM; + } +} +---- + +*Compile time (CT-refine, before any reduction).* The keystone judgment +(constitution §5) checks: (1) `Hob` has `Heat` ✓; (2) requested `target: 68C` +and `GENTLE` slope lie inside `Hob.Heat` range `0C..250C` after the `PRECISION` +style tightens slope jitter ✓; (3) for `Milk`, the requested exposure +stem:[68\mathrm{C}] is _strictly below_ the hard `curdle: 82C` limit ✓ — and +even below the soft `scald: 70C` quality limit, so _not even a warning_. The +program type-checks. Had the recipe asked `target: 84C`, clause (3) would fail +with the named diagnostic `CURDLE` and the program would *not compile*. + +*Run time (the reduction).* With stem:[\delta = 1\text{min}], `PRECISION` +giving stem:[\beta = 1], so stem:[\mathit{fuel}_0 = 5]: + +[stem] +++++ +\begin{aligned} +&\mathit{init} : \mathsf{Run},\ \Sigma = \{m \mapsto (\text{Milk}, \text{LIQUID}, 1)\},\ \tau = 0 \\ +&\xrightarrow{\text{S-Ceremony}} \cdots \xrightarrow{\text{S-Sync-Enter}} \mathsf{bar}(\{\text{Delia}\}, \mathtt{milk\_steam}, \mathsf{seq}[s]) \\ +&\xrightarrow{\text{S-Enter}} \Theta\!\cdot\!\rho(s),\ \Delta(\text{Hob}) = \mathsf{ACTIVE}(\text{Heat}, 68\mathrm{C}),\ \mathsf{loop}(s, 5) \\ +&\xrightarrow{\text{S-Step}}^{(\times 3,\ \text{temp } 64,65,65 < 66)} \tau = 3,\ \mathsf{loop}(s, 2) \quad [\text{each tick: } \mathit{env} \prec \mathsf{danger}(\text{Milk}),\ \text{no ruin}] \\ +&\xrightarrow{\text{S-Sat}}^{(\text{temp } 66 \ge 66)} \mathsf{ok}(s, w),\ w = (\,\tau{=}4,\ \mathit{pf}: 66 \ge 66\,) \\ +&\xrightarrow{\text{S-Sync-Release}} \Theta'\!\cdot\!\mathit{witness}(\mathtt{milk\_steam}, 4) : \mathsf{Done}(\mathsf{success}) +\end{aligned} +++++ + +At no tick did stem:[\mathit{env}] reach stem:[82\mathrm{C}] (S-Step's danger +premise holds; in fact `gentle-bounded` caps the trajectory at the +stem:[68\mathrm{C}] target, and stem:[68 < 82]). The mission ends +stem:[\mathsf{Done}(\mathsf{success})] carrying the `@milk_steam` witness. The +`on_fail WARM` branch was *available* (and would have installed a hold envelope +stem:[\prec 82\mathrm{C}]) but was not taken because the witness fired within +budget. + +The Agda lowering of this exact trace is `NoCurdle.scald-milk`, with +`no-curdle : ∀ t → temp-at t < curdle-envelope` discharged by +`<-transʳ (gentle-bounded t) cap += KitchenSpeak v2.0 — Hardware Abstraction Layer & The Path to Future Hardware +:toc: +:toclevels: 3 +:icons: font + +[NOTE] +==== +Elaborates `docs/design/0001-v2-constitution.adoc` §6 (HAL projection & the +trust boundary). The reference projection is implemented in +`src/compiler/hal.ml`; the JSON shown here is what `ksc lower` actually emits, so +this document and the compiler do not drift. Authored to match the prototype. +==== + +== 1. Why a capability HAL at all + +The mission requirement (SPEC §5) is *target agnosticism*: one recipe must run on +a Miele oven, a Samsung fridge, and a robot arm alike. The v1.0 spec named three +vendor clouds (HomeConnect, SmartThings, Tuya). v2.0 inverts the dependency: a +recipe never names a wire protocol. It names *capabilities* (`Heat`, `Whisk`, +`Move`, `Plate`) with *envelopes*, and the HAL projects the well-typed program to +whatever protocol the present hardware speaks — *now* Matter, MQTT, and ROS 2; +*later* anything that can publish a capability descriptor (§5). + +Lowering is invoked *only after the checker is clean*. That is the whole game: +the type that proved the recipe safe is the type that decides which devices it may +run on (§4, the trust boundary). + +== 2. The abstract capability trace (the IR) + +A well-typed program lowers first to a backend-neutral *trace*: an ordered list of +capability invocations. This is the join point every backend projects from. + +[source,json] +---- +{ + "recipe": "PoachedEgg", + "ceremony": "Breakfast", + "target": "abstract", + "steps": [ + { + "actor": "Hob", + "appliance": "Hob", + "verb": "Heat", + "envelope": { "quantity": "Temperature", "value": 82, "unit": "82C" }, + "until": "~ temp -> 80C", + "max_duration_s": 480, + "on_fail": "ABORT", + "witness": null + } + ] +} +---- + +.Trace record schema (one per executed step) +[cols="1,1,3", options="header"] +|=== +| Field | Type | Meaning +| `actor` | string | The chef or appliance named in the source step. +| `appliance` | string | The appliance the actor resolves to (a chef projects to the appliance it controls). +| `verb` | string | The capability invoked. +| `envelope` | object\|null | The primary controlled quantity: `{quantity, value (base unit), unit}`. `null` when the step requests no primary quantity (e.g. a witness-only `Poach`). +| `reach` | number? | Present for spatial capabilities (robot arm), in cm. +| `until` | string | The postcondition (a `~` tropical threshold or an `@` echo witness). +| `max_duration_s` | number | The wall-clock budget in seconds (the termination witness). +| `on_fail` | string | `ABORT` \| `RECOVER` \| `WARM`. +| `witness` | string\|null | The `@echo` that releases the barrier, or `null`. +|=== + +All magnitudes are normalised to base units (Celsius, seconds, grams, Nm, rpm, +cm) by `units.ml`, so the trace is unambiguous regardless of the source unit. + +== 3. Backend projections + +`ksc lower --target ` appends a backend-specific object to each step. The maps +below are exactly those in `src/compiler/hal.ml`. + +=== 3.1 Matter (local fabric, no cloud) + +A capability maps to a Matter cluster + attribute write on the local fabric — no +cloud round-trip, honouring *Zero Scams*. + +[cols="1,1,1", options="header"] +|=== +| Verb | Cluster | Attribute +| `Heat` / `Bake` / `Sear` | `Thermostat` | `OccupiedHeatingSetpoint` +| `Poach` | `TemperatureControl` | `TemperatureSetpoint` +| `Cool` | `Thermostat` | `OccupiedCoolingSetpoint` +| `Whisk` | `FanControl` | `PercentSetting` +| _(other)_ | `Custom` | `Setpoint` +|=== + +[source,json] +---- +"matter": { "cluster": "Thermostat", "attribute": "OccupiedHeatingSetpoint", "value": 82 } +---- + +=== 3.2 MQTT (local broker) + +A capability maps to a topic + JSON payload on a *local* broker: + +[source,json] +---- +"mqtt": { "topic": "kitchenspeak/Hob/Heat", + "payload": { "quantity": "Temperature", "value": 82, "unit": "82C" } } +---- + +=== 3.3 ROS 2 (consumer robotics) + +A robotics capability maps to an action goal on a `control_msgs` action server; +the envelope's `reach`/torque become action constraints. + +[cols="1,2", options="header"] +|=== +| Verb | Action type +| `Move` / `Plate` | `control_msgs/action/FollowJointTrajectory` +| `Whisk` | `control_msgs/action/GripperCommand` +| _(other)_ | `control_msgs/action/JointGroupCommand` +|=== + +[source,json] +---- +"ros2": { "server": "/arm/plate", "action": "control_msgs/action/FollowJointTrajectory" } +---- + +== 4. The trust boundary (the load-bearing idea) + +The compile-time safety proof holds *given* two classes of postulate (constitution +§6, COMMENTARY §Echo): the *echo oracles* (sensors are sound) and the *controller +contracts* (a GENTLE slope keeps to its envelope). Lowering is where those +postulates must be *discharged by real hardware*: + +. *Echo postulates → local sensors.* Every `proving @e` and every `until @e` + obliges the target device to supply a *local* sensor of the echo's modality. If + the device cannot, the program does not lower to it. (Zero Scams forbids + discharging a safety witness over the cloud.) +. *Controller contracts → certified envelopes.* A step's requested envelope was + checked against the appliance type's capability envelope. At lowering, the + *physical* device must carry a certificate that its envelope is at least as + tight. A device whose certified envelope is wider (it can overshoot) fails to + bind that capability. + +Operationally the boundary is visible in the tool: `ksc lower` runs the checker +first and *refuses* to emit a trace for an unsafe recipe — + +[source] +---- +$ ksc lower --target matter examples/v2/negative/curdle-milk.ks +curdle-milk.ks:7: error [CURDLE] would expose material 'm' to 95C; hard curdle limit is 82C +refusing to lower curdle-milk.ks: 1 safety error(s) — the trust boundary holds. +---- + +The type that made the recipe safe is the type that gates deployment. + +== 5. Device certification — how *future* hardware plugs in + +A new or future appliance joins the ecosystem by publishing a *device descriptor*: +the very same shape as an `appliance` declaration. The descriptor *is* the +appliance type. Once registered, every existing recipe type-checks against it with +no source change — the "make use of future hardware" story. + +[source] +---- +// A future induction hob registers itself by descriptor: +appliance InductionHob { + capability Heat { + quantity: Temperature; + range: 0C .. 240C; // its certified envelope (note: tighter than 250C) + slope: 0C/s .. 12C/s; // faster than a gas hob — still bounded + } + capability Sear { quantity: Temperature; range: 0C .. 240C; slope: 0C/s .. 20C/s; } +} +---- + +Because envelopes are *subtyped by inclusion* (constitution §5), a recipe written +against the abstract `Hob` lowers to `InductionHob` iff every step it uses stays +inside `InductionHob`'s narrower envelope — checked, not hoped. A device that +*widens* a hazard envelope (e.g. a hob that can reach 400C) simply causes recipes +that relied on the 250C ceiling to be re-checked; ones that stay safe still lower. + +== 6. Consumer robotics specifics + +A robot arm is just an appliance whose capabilities carry *spatial* and +*mechanical* envelopes in addition to the controlled quantity: + +[source] +---- +appliance Arm { + capability Move { quantity: Torque; range: 0Nm .. 8Nm; reach: 0cm .. 85cm; } + capability Plate { quantity: Torque; range: 0Nm .. 5Nm; reach: 0cm .. 85cm; } +} +---- + +The checker enforces three robotics-specific safeties (see `check.ml`): + +* *Over-torque* — a `torque:` above the capability range ⇒ `OVER_TORQUE`. +* *Over-reach* — a `reach:` beyond the arm's reach envelope ⇒ `OVER_REACH`. +* *Material handling force* — a delicate target may carry a `mechanical { … }` + hard limit (max handling force); a `Plate`/`Move` exceeding it is rejected by + the same material-safety rule that rejects thermal ruin. + +A future revision adds *human-safety zones* as a chemical/spatial envelope on the +workspace: a `Move` whose trajectory would enter a declared keep-out zone is a +compile error, not a run-time emergency stop. The envelope machinery already +present (intersection of device + target safety) is the natural home for it. + +== 7. Summary + +* Recipes name capabilities, never wire protocols. +* A well-typed program lowers to a backend-neutral capability trace. +* Matter / MQTT / ROS 2 are projections of that trace; new backends are additive. +* The proven/postulated boundary becomes a deploy-time gate: unsafe recipes do + not lower, and devices that cannot honour an envelope do not bind. +* Future hardware joins by descriptor; existing recipes re-check automatically. diff --git a/docs/design/0004-stdlib-ontology.adoc b/docs/design/0004-stdlib-ontology.adoc new file mode 100644 index 0000000..81df60e --- /dev/null +++ b/docs/design/0004-stdlib-ontology.adoc @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — Standard Ontology: Materials, Tools, Appliances +:toc: +:toclevels: 3 +:icons: font + +[NOTE] +==== +Elaborates `docs/design/0001-v2-constitution.adoc` §2. The numbers here are the +ones the reference checker actually ships, in `src/compiler/stdlib_ks.ml`; the +worked recipes are the corpus in `examples/v2/`. Recipes may `use` these built-ins +without redeclaring; a file-level declaration of the same name overrides a +built-in. + +These are *conservative engineering defaults*, overridable per recipe. Where +sources disagree, the safer (lower) hard limit is chosen. A `soft` limit is a +*quality* threshold and only warns; a `hard` limit is a *ruin/hazard* threshold +and rejects. Only the *heating* direction is modelled as a hazard in v2.0 (the +lower/freezing direction is future work). +==== + +== 1. Materials + +[cols="1,1,2,3", options="header"] +|=== +| Material | Phase | Envelope (soft / *hard*) | Rationale + +| `Milk` | LIQUID | scald 70C / *curdle 82C* | Proteins denature and the emulsion breaks as it approaches the boil; ~82C is a conservative curdle point. 70C "scald" is the classic technique temperature — a quality cue, not ruin. +| `Egg` | RAW | coagulate 63C / *scorch 200C* | White sets ~63C, yolk ~70C — *desired*, hence soft. Dry scorching is the hazard. +| `Water` | LIQUID | boil 100C / — | Boiling is fine for many uses; a soft cue for techniques (poaching) that want sub-boil. +| `Vinegar`| LIQUID | — | Acidulant; no thermal hazard at kitchen temperatures. +| `Chocolate` | SOLID | temper 32C / *scorch 55C* | Above the tempering band quality collapses; it scorches/seizes well below water's boil. +| `Butter` | SOLID | brown 150C / *burn 175C* | Milk solids brown then burn; beurre noisette lives in the soft band. +| `Oil` | LIQUID | smoke 190C / *scorch 230C* | Smoke point ~190C (varies by oil); degradation/acrolein hazard above. +| `Cream` | LIQUID | split 80C / *scorch 95C* | Splits if pushed too hot or over-churned. +| `Steak` | RAW | maillard 140C / *scorch 260C* | Maillard browning is the goal (soft); scorch is ruin. +| `Flour` | RAW_POWDER | — | Inert until bound (see Dyadic bind → Dough). +| `Sugar` | SOLID | caramel 160C / *burn 190C* | Caramel stages begin ~160C; acrid burn above. +| `Dough` | KNEADABLE | — | The product of `(Flour, Water) <~> Actor -> Dough`. +|=== + +== 2. Tools (inert implements with material limits) + +A tool is non-linear (reusable, never `use`d) but still carries an envelope — +this is what makes "bake the spatula" a compile error. + +[cols="1,1,2,3", options="header"] +|=== +| Tool | Material | Envelope (*hard*) | Rationale +| `Spatula` | SILICONE | *melt 260C*, *char 320C* | Food-grade silicone tops out ~260C. +| `WoodenSpoon` | WOOD | *char 250C* | Chars/ignites in a hot dry pan or oven. +| `NylonSpatula` | NYLON | *melt 190C* | Cheap nylon melts low — the cautionary tool. +| `SteelWhisk` | STAINLESS | *anneal 800C* | Effectively safe at kitchen temperatures. +|=== + +== 3. Appliances (capability envelopes) + +[cols="1,1,1,2", options="header"] +|=== +| Appliance | Capability | Controlled quantity | Envelope +| `Hob` | `Heat` | Temperature | 0–250C, slope 0–5C/s, modalities visual+acoustic +| `Hob` | `Poach` | Temperature | 0–100C, *consumes target*, modality visual +| `Hob` | `Sear` | Temperature | 0–250C, slope 0–8C/s +| `Oven` | `Bake` | Temperature | 50–300C +| `SousVide` | `Heat` | Temperature | 0–95C, slope 0–0.5C/s +| `Blender` | `Whisk` | AngularRate | 0–1200 rpm +| `Fridge` | `Cool` | Temperature | −20–8C +| `Arm` (ROS 2) | `Move` | Torque | 0–8Nm, reach 0–85cm +| `Arm` (ROS 2) | `Whisk` | AngularRate | 0–1500 rpm +| `Arm` (ROS 2) | `Plate` | Torque | 0–5Nm, reach 0–85cm, *consumes target* +|=== + +The chef *style* modifier may only *shrink* an envelope: `PRECISION` caps an +appliance's slope at 50% of its rating, `BALANCED` at 80%, `POWER` at 100%. + +== 4. Worked recipes (and the one-line break that makes each ill-typed) + +Each is in `examples/v2/`; the "break" column is the corresponding entry in +`examples/v2/negative/`, with the exact error the checker emits. + +[cols="1,2,2", options="header"] +|=== +| Recipe | Types & checks exercised | One-line break → error + +| `ScaldMilk` (`scald-milk.ks`) +| Primitive (°C), Tropical (GENTLE slope), Material (milk). Passes with a soft + `APPROACHING_SCALD` warning at 70C. +| `target: 70C` → `target: 95C` ⇒ *CURDLE* (95 ≥ hard 82C). + +| `SearSteak` (`sear-steak.ks`) +| Tropical + Tool safety: a silicone `Spatula` (melt 260C) in a 200C sear is fine; + soft `APPROACHING_MAILLARD` warning. +| `Hob.Sear(... 200C)` → `Oven.Bake(s, Spatula, 300C)` ⇒ *MELT_TOOL* (300 ≥ 260C) + (and *SCORCH* on the steak). + +| `Mayonnaise` (`mayonnaise.ks`) +| Linear (`use` yolk, oil), Dyadic (`(yolk,oil) <~> Blender -> Mayo`), + Choreographic (Blender then Arm), Echo (`@plated`), robotics (`Plate`). +| Re-whisk the consumed `Mayo` ⇒ *DOUBLE_USE*; or `torque: 9Nm` on the arm ⇒ + *OVER_TORQUE*. + +| `PoachedEgg` (`poached-egg.ks`) +| The v1.0 triangle ported: Linear (egg, vinegar consumed), Tropical (water ramp), + Echo (`@water_shimmer`, `@white_set`), Ceremonial (`Breakfast`). +| Drop `proving @white_set` ⇒ *UNWITNESSED_SYNC*; reuse the egg ⇒ *DOUBLE_USE*. + +| `PlateGarnish` (`robot-plate.ks`) +| Consumer robotics: torque + reach envelopes, echo `@placed`. +| `reach: 40cm` → `reach: 120cm` ⇒ *OVER_REACH* (120 > 85cm). +|=== + +== 5. The full error vocabulary + +The checker emits these codes (see `docs/spec/0003-type-system.adoc` and +`check.ml`): + +[cols="1,3", options="header"] +|=== +| Code | Meaning +| `CURDLE` / `SCORCH` / `MELT_TOOL` / `SEIZE` / `SPLIT` | a hard material/tool thermal (or mechanical) limit would be reached +| `OVER_RANGE` / `UNDER_RANGE` | requested quantity outside the appliance's device envelope +| `OVER_TORQUE` / `OVER_SPEED` / `OVER_REACH` / `OVER_SLOPE` | a device mechanical/rate/spatial envelope exceeded +| `NO_CAPABILITY` | the appliance lacks the requested verb +| `DIM_MISMATCH` | an argument's dimension does not match the controlled quantity +| `DOUBLE_USE` / `UNUSED_RESOURCE` / `UNDECLARED_TARGET` / `LEAK` | linearity violations +| `MISSING_UNTIL` / `MISSING_MAXDUR` / `MISSING_ONFAIL` | a missing mandatory step construct +| `UNWITNESSED_SYNC` / `UNKNOWN_ECHO` | echo-witness obligations +| `APPROACHING_` | (warning) a soft quality threshold reached +|=== + +== 6. Extending the ontology + +* *New material*: declare `material X { phase: …; thermal { soft a: …; hard b: … } }`. + Conservative defaults; cite the source in a comment. +* *New appliance*: publish a descriptor (= an `appliance` declaration; see + `docs/design/0003-hal-and-hardware.adoc` §5). Existing recipes re-check against + it automatically. +* *Per-recipe override*: a file-level declaration shadows a built-in of the same + name, so a recipe may tighten (never silently widen) a hazard limit for its own + context. diff --git a/docs/spec/0001-lexical-grammar.adoc b/docs/spec/0001-lexical-grammar.adoc new file mode 100644 index 0000000..a37daf4 --- /dev/null +++ b/docs/spec/0001-lexical-grammar.adoc @@ -0,0 +1,663 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — Lexical Grammar +:toc: +:toclevels: 3 +:icons: font +:stem: latexmath + +[NOTE] +==== +This document is *normative*. It closes open item *O3* of `grammar.ebnf` +("`actors`, `actor`, `action`, `params`, `metric`, `comparison`, `value`, +`time`, `error_handle`, `identifier`, `integer`, `ceremony_id`, `echo_id`, +`resource_id`, `actor_id` are lexical / sub-grammar placeholders") by giving a +complete, automaton-level specification of the KitchenSpeak token stream. + +It elaborates — and must not contradict — `docs/design/0001-v2-constitution.adoc` +(the binding contract). In particular the dimension set of constitution §2.1, +the keyword vocabulary of §2.2–§2.5 and §3, the slope/style envelope-modifier +semantics of §2.4/§2.5, and the safety judgment of §5 are taken as given here; +this document supplies the *lexical surface* that feeds them. Where the +constitution writes a token (`82C`, `<~>`, `GENTLE`, `Pa·s`), this document +fixes its exact spelling, its automaton, and its normalised value. + +The lexer is the boundary between bytes and the parser. It produces a stream of +*tokens*, each a triple stem:[(class, lexeme, value)] where stem:[value] is a +class-specific payload (e.g. a normalised dimensioned quantity for a numeric +literal). The parser of `grammar.ebnf` consumes that stream; the placeholders of +O3 are resolved to the token classes named in <>. +==== + +== 1. Source representation, whitespace, and Unicode + +=== 1.1 Encoding + +A KitchenSpeak source file is a stream of Unicode scalar values encoded as +*UTF-8*. The lexer decodes UTF-8 to scalar values *before* tokenisation; all +automata in this document range over scalar values, not bytes. A byte sequence +that is not well-formed UTF-8 is a *lexical error* (`E-ENCODING`) raised at the +offending byte offset. + +The optional UTF-8 byte-order-mark (U+FEFF) is permitted *only* as the first +scalar of the file and is discarded. A U+FEFF anywhere else is `E-ENCODING`. + +=== 1.2 Unicode policy + +KitchenSpeak is deliberately *near-ASCII* in its identifier and keyword surface +to keep recipes typable on any keyboard. Exactly three non-ASCII scalars are +admitted, each in one fixed role: + +[cols="1,2,4", options="header"] +|=== +| Scalar | Name | Sole admitted role + +| U+00B7 `·` | MIDDLE DOT | The unit-product separator inside a compound unit suffix (`Pa·s`, and only there — see <>). Never an operator, never in an identifier. +| U+00B0 `°` | DEGREE SIGN | *Optional* ornament immediately before a temperature unit letter: `82°C` is accepted as an exact synonym of `82C`. It carries no value of its own. +| U+03BC `μ` | GREEK SMALL LETTER MU | The SI micro- prefix, admitted only as the first scalar of a recognised unit suffix (`μm`, `μs`). The ASCII fallback spelling `um`/`us` is also accepted and normalises identically. +|=== + +Every other scalar outside the ASCII range U+0000–U+007F is rejected with +`E-UNICODE` if it appears anywhere except inside a comment or a string-valued +echo modality literal (neither of which is constrained). Notably, "smart quotes", +en/em dashes, and non-breaking spaces are *not* silently accepted; this prevents +copy-paste hazards in a safety language. + +[NOTE] +==== +The `·` of `Pa·s` is the canonical form. For keyboard-only authoring, the ASCII +digraph `Pa.s` is *not* accepted (it would collide with the member-access `.` +operator under maximal munch); instead the ASCII fallback `Pas` is accepted and +normalises to the same dimension. Authors are encouraged to use `·`. +==== + +=== 1.3 Whitespace and line terminators + +[cols="1,3", options="header"] +|=== +| Class | Scalars + +| Inline whitespace | U+0020 SPACE, U+0009 TAB. +| Line terminator | U+000A LF, or the pair U+000D U+000A (CRLF, normalised to a single LF), or a lone U+000D CR (normalised to LF). +|=== + +Whitespace and line terminators are *not* significant to the grammar: they +separate tokens and are otherwise discarded (KitchenSpeak is free-form, not +layout-sensitive). Their *only* lexical force is to terminate maximal munch +(<>) and to bound line comments. A vertical tab (U+000B), form feed +(U+000C), or other Unicode whitespace is `E-UNICODE`. + +Tokens are separated either by whitespace or by an adjacency that is +unambiguous under maximal munch. `82C` is one token; `82 C` is the numeric +literal `82C`? — *no*: a digit run followed by whitespace then a bare letter is +two tokens (`82` is dimensionless, `C` is an identifier) and will fail the +parser, not the lexer. A unit suffix must be *adjacent* to its magnitude. + +== 2. Comments + +[cols="1,1,4", options="header"] +|=== +| Form | Syntax | Semantics + +| Line comment | `//` … to end of line | From the `//` up to but not including the next line terminator (or EOF). Discarded. Not recursive. +| Block comment | `/*` … `*/` | From `/*` to the matching `*/`. *Nesting is supported*: `/*` and `*/` are balanced, so `/* a /* b */ c */` is a single comment. Discarded. +|=== + +Block comments may span line terminators. An unterminated block comment (EOF +before the outermost `*/`) is `E-COMMENT`. A `*/` with no open block comment is +not special — it tokenises as `*` `/` (and the parser rejects it). Comments are +recognised everywhere whitespace is, and bind tighter than maximal munch: the +two-scalar opener `/*` is recognised before `/` could be taken as an operator. + +[source] +---- +// line comment — discarded to EOL +material Milk { /* curdle point is the + hard limit; see §2.2 of the constitution */ + thermal { curdle: 82C } +} +---- + +== 3. Token classes (overview) + +The lexer emits exactly these top-level classes. Each is specified by an +automaton in the sections that follow. + +[cols="1,3,3", options="header"] +|=== +| Class | Role | Section + +| `KEYWORD` | Reserved words of the language. | <> +| `SLOPE_TAG` | `GENTLE` / `MODERATE` / `AGGRESSIVE`. | <> +| `STYLE_TAG` | `PRECISION` / `POWER` / `BALANCED`. | <> +| `FAIL_TAG` | `ABORT` / `RECOVER` / `WARM`. | <> +| `PHASE_TAG` | `LIQUID`, `SET`, `RAW`, `COOKED`, … phase typestate literals. | <> +| `IDENT` | User-chosen names. | <> +| `QTY` | Dimensioned numeric literal (`82C`, `0.8Pa·s`). | <> +| `NUM` | Bare (dimensionless) numeric literal. | <> +| `ECHO_REF` | `@name` — an echo reference. | <> +| `OP` | Punctuation and operators (`~ @ <~> -> .. . : ; , ( ) { }`). | <> +| `EOF` | End of input (synthetic, one terminal token). | — +|=== + +`SLOPE_TAG`, `STYLE_TAG`, `FAIL_TAG`, and `PHASE_TAG` are split out from +`KEYWORD` because they are *value-carrying* enumerated tokens (they have numeric +or typestate denotations), whereas keywords are pure syntax. All four are +nonetheless reserved (an identifier may not collide with them — see <>). + +== 4. Keywords [[keywords]] + +The following words are *reserved keywords*. They are matched as `IDENT`-shaped +runs and then reclassified by exact, *case-sensitive* lookup (see <>): +an identifier-shaped lexeme that is in this table becomes a `KEYWORD`, otherwise +it stays `IDENT`. + +[cols="1,1,1", options="header"] +|=== +| Declaration heads | Mission & step | Capability / envelope + +| `material` | `recipe` | `capability` +| `tool` | `under` | `range` +| `appliance` | `use` | `slope` +| `chef` | `as` | `reach` +| `controls` | `sync` | `thermal` +| `style` | `proving` | `mechanical` +| `echo` | `until` | `chemical` +| `on` | `max_duration` | `phase` +| `ceremony` | `on_fail` | `soft` +| | | `hard` +|=== + +[NOTE] +==== +*Compatibility with v1.0.* The class v1.0 grammar (`SPEC.adoc`, `grammar.ebnf` +Section A) uses `orchestrate` as the mission head. The v2.0 constitution §3 +renames the mission head to `recipe`. To keep the constitution §9 promise +("every well-typed v1.0 program remains well-typed"), `orchestrate` is a +*reserved keyword alias* for `recipe`: both lex to a `KEYWORD` and the parser +treats them identically. `orchestrate` is therefore included in the reserved set +even though it does not appear in the table above. +==== + +The complete reserved set is the union of: the table above, the alias +`orchestrate`, and the enumerated value-tokens of <> (`GENTLE`, +`MODERATE`, `AGGRESSIVE`, `PRECISION`, `POWER`, `BALANCED`, `ABORT`, `RECOVER`, +`WARM`, and the phase tags). The literals `true` and `false` are *also* reserved +(they appear in `until @white_set == true`) and lex as `KEYWORD` carrying a +boolean value. + +=== 4.1 Reservation rule [[reserved]] + +[stem] +++++ +\mathrm{classify}(\ell) = +\begin{cases} +\textsf{KEYWORD} & \ell \in \mathcal{K} \\ +\textsf{SLOPE\_TAG} & \ell \in \{\texttt{GENTLE},\texttt{MODERATE},\texttt{AGGRESSIVE}\} \\ +\textsf{STYLE\_TAG} & \ell \in \{\texttt{PRECISION},\texttt{POWER},\texttt{BALANCED}\} \\ +\textsf{FAIL\_TAG} & \ell \in \{\texttt{ABORT},\texttt{RECOVER},\texttt{WARM}\} \\ +\textsf{PHASE\_TAG} & \ell \in \mathcal{P} \\ +\textsf{IDENT} & \text{otherwise} +\end{cases} +++++ + +where stem:[\mathcal{K}] is the keyword table (incl. `orchestrate`, `true`, +`false`) and stem:[\mathcal{P}] is the phase set of <>. No user identifier +may equal any reserved word; the lexer never emits `IDENT` for a reserved lexeme, +so the parser cannot bind one. + +== 5. Identifiers [[ident]] + +An identifier names a material, tool, appliance, chef, ceremony, capability +verb, echo, resource binding, argument key, or metric. + +.Regular definition +[source] +---- +ident_start ::= [A-Za-z_] +ident_cont ::= [A-Za-z0-9_] +IDENT ::= ident_start ident_cont* +---- + +.Automaton +[stem] +++++ +\xrightarrow{\;[A\text{-}Za\text{-}z\_]\;} q_1 \;\;(\circlearrowleft [A\text{-}Za\text{-}z0\text{-}9\_])\;\; \Rightarrow \textsf{accept} +++++ + +Identifiers are *case-sensitive* and ASCII-only (Unicode letters outside ASCII +are `E-UNICODE`, by §1.2). There is no length limit. By convention (not enforced +lexically) declared *types* (materials, appliances, chefs, echoes) are +`PascalCase` or `Snake_Case` (`Delia_Hob`, `Baridi_Robot`), verbs are +`PascalCase` (`Heat`, `Poach`, `Knead`), and argument keys are `lower_snake` +(`target`, `slope`, `acidulant`, `depth`). The capability verb in +`actor.Verb(...)` and the metric in `~ metric cmp qty` are both `IDENT`. + +The leading `@` of an echo reference is *not* part of the identifier; see +<>. The identifier body after `@` obeys the `IDENT` automaton. + +== 6. Numeric and dimensioned literals [[numeric]] + +This is the heart of the lexical layer: the surface form of the +*dimension-indexed quantities* of constitution §2.1. + +=== 6.1 The magnitude + +[source] +---- +digit ::= [0-9] +int_part ::= digit { digit } (* no leading-zero rule; "082" allowed, value 82 *) +frac_part ::= "." digit { digit } +magnitude ::= int_part [ frac_part ] +---- + +The magnitude is a non-negative decimal. There is *no* sign token at the lexical +level — negative envelope endpoints do not occur in KitchenSpeak (temperatures, +masses, torques, rates, lengths, viscosities, powers, durations are all +non-negative magnitudes; a "below freezing" limit such as `0C` is expressed in +its own unit). The magnitude is read as an exact rational: `0.8` is +stem:[8/10], not an IEEE double. There is no exponent (`e`) syntax; recipe +quantities are human-scale. + +A magnitude with a `.` but no fractional digit (`5.`) is `E-NUMBER`; a `.` +with no integer part (`.8`) is *not* a number at all (the leading `.` would be +the `.` or `..` operator — see maximal munch). + +=== 6.2 The unit suffix and the dimension map [[units]] + +A magnitude *immediately followed* (no whitespace) by a recognised *unit +suffix* is a `QTY`; a magnitude with no suffix is a `NUM` (dimension +`Dimensionless`). The suffix automaton matches the longest recognised unit +string (maximal munch within the suffix table). The full normative suffix table: + +[cols="1,1,1,2,1", options="header"] +|=== +| Suffix | Dimension (constitution §2.1) | Base unit | Normalisation: stem:[mag_{base} =] | Example → base + +| `C` | Temperature | °C | stem:[mag] | `82C` → 82 °C +| `°C` | Temperature | °C | stem:[mag] | `82°C` → 82 °C +| `K` | Temperature | °C | stem:[mag - 273.15] | `355.15K` → 82 °C +| `s` | Time | s | stem:[mag] | `90s` → 90 s +| `ms` | Time | s | stem:[mag / 1000] | `500ms` → 0.5 s +| `μs` / `us`| Time | s | stem:[mag / 10^{6}] | `2000us` → 0.002 s +| `min` | Time | s | stem:[mag \times 60] | `5min` → 300 s +| `h` | Time | s | stem:[mag \times 3600]| `2h` → 7200 s +| `g` | Mass | g | stem:[mag] | `300g` → 300 g +| `kg` | Mass | g | stem:[mag \times 1000]| `1.5kg` → 1500 g +| `mg` | Mass | g | stem:[mag / 1000] | `250mg` → 0.25 g +| `Nm` | Torque | N·m | stem:[mag] | `8Nm` → 8 N·m +| `mNm` | Torque | N·m | stem:[mag / 1000] | `800mNm` → 0.8 N·m +| `rpm` | AngularRate | rad/s | stem:[mag \times 2\pi/60] | `80rpm` → 8.3776 rad/s +| `rad/s` | AngularRate | rad/s | stem:[mag] | `5rad/s` → 5 rad/s +| `m` | Length | m | stem:[mag] | `2m` → 2 m +| `cm` | Length | m | stem:[mag / 100] | `4cm` → 0.04 m +| `mm` | Length | m | stem:[mag / 1000] | `12mm` → 0.012 m +| `μm` / `um`| Length | m | stem:[mag / 10^{6}] | `50um` → 0.00005 m +| `Pa·s` | Viscosity | Pa·s | stem:[mag] | `0.8Pa·s` → 0.8 Pa·s +| `Pas` | Viscosity | Pa·s | stem:[mag] | `0.8Pas` → 0.8 Pa·s (ASCII fallback) +| `mPa·s` | Viscosity | Pa·s | stem:[mag / 1000] | `800mPa·s` → 0.8 Pa·s +| `W` | Power | W | stem:[mag] | `100W` → 100 W +| `kW` | Power | W | stem:[mag \times 1000]| `2kW` → 2000 W +| `C/s` | Temperature / Time | °C/s | stem:[mag] | `2C/s` → 2 °C/s +| `°C/s` | Temperature / Time | °C/s | stem:[mag] | `5°C/s` → 5 °C/s +| `K/s` | Temperature / Time | °C/s | stem:[mag] | `2K/s` → 2 °C/s (rate; offset cancels) +| `rpm/s` | AngularRate / Time | rad/s² | stem:[mag \times 2\pi/60] | `40rpm/s` → 4.1888 rad/s² +|=== + +Each `QTY` token's *value* is the pair stem:[(mag_{base},\ \mathbf{d})] where +stem:[\mathbf{d}] is the *dimension vector* (the §2.1 `dim`), e.g. `Nm` carries +stem:[\mathbf{d} = \texttt{Torque}], `C/s` carries +stem:[\mathbf{d} = \texttt{Div(Temperature, Time)}], `Pa·s` carries +stem:[\mathbf{d} = \texttt{Viscosity}] (which is definitionally +stem:[\texttt{Mul(Pressure, Time)}] but kept atomic for the kitchen set). +Normalisation to base unit is applied at lex time so the type checker compares +magnitudes in one canonical unit per dimension; the *original lexeme* is retained +for diagnostics. + +[IMPORTANT] +==== +Note the deliberate distinction for *temperature* vs *temperature rate*. A +Kelvin temperature carries the additive offset (`K` → subtract 273.15); a Kelvin +*rate* (`K/s`) does not, because a rate is a difference and the offset cancels. +The lexer applies the offset only for the absolute-temperature suffixes +(`C`, `°C`, `K`), never inside a `.../s` compound. This is the one place where +the suffix's structure changes the normalisation, and it is exactly the place a +naive lexer would scorch the egg by treating 2 K/s as 2 − 273.15. +==== + +=== 6.3 Compound-unit lexical structure + +A *compound* unit suffix is one containing `/` (a quotient: `C/s`, `rad/s`, +`rpm/s`, `K/s`) or `·` (a product: `Pa·s`, `mPa·s`). Compound suffixes are +matched *atomically* by the suffix automaton — the `/` inside `C/s` is *part of +the unit token*, not the division operator, and the `·` inside `Pa·s` is the +unit-product separator of §1.2, never an operator. This is resolved entirely by +maximal munch over the suffix table: from the magnitude's end, the lexer greedily +matches the longest entry in the table. + +.Suffix automaton (informal NFA, longest-match) +[source] +---- +After magnitude, attempt suffixes in this priority (longest literal wins): + "°C/s" | "rpm/s" | "rad/s" | "mPa·s" | "Pa·s" | "Pas" | "C/s" | "K/s" +| "°C" | "mNm" | "Nm" | "min" | "rpm" | "kg" | "mg" | "ms" +| "μs"|"us" | "μm"|"um" | "kW" | "cm" | "mm" +| "C" | "K" | "s" | "h" | "g" | "m" | "W" +If none matches → the magnitude is a NUM (Dimensionless). +A suffix-shaped run not in the table (e.g. "82Q") → E-UNIT at the suffix. +---- + +The single-letter suffixes are listed *last* so that `min` is never mis-read as +`m` (Length) followed by identifier `in`, and `ms` is never `m`+`s`, and `Nm` +beats `N`+`m`. Adjacency is mandatory: `5 min` (with a space) is `NUM 5` then +`KEYWORD`? no — `min` is not a keyword, so it is `IDENT min`; the parser rejects +the pair. Only the adjacent `5min` is a `QTY`. + +A magnitude immediately followed by an `ident_start` scalar that does *not* begin +any table suffix (e.g. `82x`) is `E-UNIT`: a number butting against letters is +always an attempted unit and an unrecognised one is an error, never silently two +tokens. (This makes mis-typed units loud rather than silent.) + +== 7. Enumerated value tokens [[enums]] + +These tokens are reserved words that *carry a value*. Their denotations are +fixed by constitution §2.4 (slope), §2.5 (style/chef), §3 (`on_fail`), and §2.2 +(phase typestate). + +=== 7.1 Slope tags + +A `SLOPE_TAG` selects a *bounded-derivative envelope* — a multiplicative ceiling +on the rate at which a capability may approach its setpoint, expressed as a +fraction of the appliance capability's declared `slope` range +(constitution §2.4, `slope: 0C/s .. 5C/s`). The tag scales the *upper* slope +bound the controller is permitted to command: + +[cols="1,1,3", options="header"] +|=== +| `SLOPE_TAG` | Envelope-modifier (fraction of declared max slope) | Reading + +| `GENTLE` | stem:[0.30 \times s_{max}] | Slow, overshoot-averse approach. The poached-egg `slope: GENTLE` caps the Hob (max 5 °C/s) at 1.5 °C/s. +| `MODERATE` | stem:[0.65 \times s_{max}] | Balanced default. +| `AGGRESSIVE` | stem:[1.00 \times s_{max}] | Full declared slope; never exceeds the appliance envelope (the cap is the device's own ceiling). +|=== + +The value carried by the token is the multiplier stem:[\{0.30, 0.65, 1.00\}]. +Crucially, a slope tag can only *shrink or meet* the appliance envelope, never +widen it (stem:[\le 1.00]); this is the lexical expression of the +constitution §5 rule that requested envelopes must lie *inside* capability +ranges. The type checker reads slope tags through this table when computing the +"req ⊆ cap" obligation. + +=== 7.2 Style tags + +A `STYLE_TAG` is a *chef-level* envelope modifier (constitution §2.5: "Style +tightens slopes/jitter; it can only shrink envelopes, never widen them"). Where a +slope tag scales the approach rate of a single step, a style tag applies a +persona-wide tightening to *both* the slope ceiling and the permitted jitter +(setpoint tolerance band): + +[cols="1,1,1,3", options="header"] +|=== +| `STYLE_TAG` | slope factor | jitter factor | Reading + +| `PRECISION` | stem:[\times 0.50] | stem:[\times 0.25] | Delia: low jitter, high accuracy. Halves the slope ceiling and quarters the tolerance band. Used for poaching, tempering, emulsions. +| `BALANCED` | stem:[\times 1.00] | stem:[\times 1.00] | Identity modifier; the appliance/slope envelope is used as-is. +| `POWER` | stem:[\times 1.00] | stem:[\times 1.50] | Jamie: full slope, *looser* tolerance band (faster settling at the cost of overshoot margin). Note the slope factor is capped at 1.00 — POWER never widens the slope past the appliance envelope, it only relaxes jitter. +|=== + +The token value is the pair stem:[(slope\_factor,\ jitter\_factor)]. Style and +slope compose multiplicatively and the product is *clamped* at the appliance +envelope: a `PRECISION` chef issuing a `GENTLE` step commands at most +stem:[0.50 \times 0.30 = 0.15] of the device's max slope. Because every factor is +stem:[\le 1] on the slope axis, the constitution's "style can only shrink" rule +holds by construction at the lexical-value level. (The jitter axis may exceed 1 +for `POWER`, which loosens a *quality* band, not a *safety* ceiling — it can never +push past a material's hard limit, which is enforced separately in §5.) + +=== 7.3 Fail tags + +A `FAIL_TAG` is the value of the mandatory `on_fail` clause (constitution §3, §5 +totality obligation). Each names a constructor of the step's failure image +stem:[\{success\} \uplus \{aborted, recovered, warmed\}]: + +[cols="1,3", options="header"] +|=== +| `FAIL_TAG` | Denotation +| `ABORT` | Stop the mission; release resources to safe state; the step's linear inputs are recorded as consumed-on-failure. The `Aborted` constructor (cf. `PoachedEgg.agda` / `Dough.agda`). +| `RECOVER` | Attempt a bounded recovery (supervisor override / extension) within the same `max_duration` envelope. The `Recovered` constructor. +| `WARM` | Hold the partial result at a safe holding temperature pending operator attention. The `HeldWarm` family. +|=== + +=== 7.4 Phase tags + +A `PHASE_TAG` is a phase-typestate literal (constitution §2.2). The reserved +phase set stem:[\mathcal{P}] for v2.0 is: + +[cols="1,3", options="header"] +|=== +| Group | Members +| Stable phases | `LIQUID`, `SOLID`, `GAS`, `SET`, `WHOLE`, `RAW`, `COOKED`, `CHOPPED`, `KNEADABLE`, `KNEADED`, `RAW_POWDER`, `AT_POACH_TEMP`, `COLD` +| Ruin phases (absorbing failure states) | `CURDLED`, `SCORCHED`, `BURNT` +|=== + +Phase tags carry their identity plus a boolean `is_ruin` flag (true for the +ruin group). The checker forbids any typestate transition *into* a ruin phase +(constitution §2.2: "the *ruin* phases … are absorbing failure states the +checker forbids entering"). The phase set is *closed* at the lexical level: a new +phase requires a spec amendment, so that a typo'd phase (`LIQIUD`) lexes as a +bare `IDENT` and is caught by the parser/checker rather than silently admitted as +a novel typestate. + +== 8. Sigil tokens: `@` and `~` [[sigils]] + +Two scalars introduce *prefix-sigil* constructs. They are subtle because each is +both a stand-alone operator and a token prefix; maximal munch (<>) +disambiguates. + +=== 8.1 `@` — echo reference (`ECHO_REF`) + +[source] +---- +ECHO_REF ::= "@" ident_start ident_cont* +---- + +An `@` *immediately followed* by an `ident_start` scalar is a single `ECHO_REF` +token whose value is the identifier body (the `@white_set`, `@water_shimmer`, +`@viscosity` of the examples). The `@` is *not* an operator in this position. An +`@` *not* followed by `ident_start` (e.g. `@ x` with a space, or `@(`) is the +bare `@` operator (`OP`) — used in the v1.0 `echo_witness` production +`"@", metric, comparison, value`. In practice nearly every `@` is an `ECHO_REF`; +the bare-operator case exists only to keep the v1.0 grammar's literal `"@"` +terminal lexable. + +=== 8.2 `~` — tropical sigil + +`~` is always the bare tropical operator (`OP`). It is *not* a token prefix: in +`until ~ temp >= 80C`, the `~` is one token, `temp` an `IDENT`, `>=` an `OP`, +`80C` a `QTY`. There is no `~name` fused form (unlike `@`), because the +tropical-threshold production puts a metric *identifier* after the sigil, not a +fused name. + +== 9. Operators and punctuation [[operators]] + +All emit class `OP`; the value is the canonical operator string. Listed in +*maximal-munch priority* (longest first within a shared prefix): + +[cols="1,1,3", options="header"] +|=== +| Lexeme | Name | Role + +| `<~>` | dyadic bind | The Dyadic tensor `(X, Y) <~> Actor` (constitution §3, `Dough.agda`). Three scalars; munched before `<`, `~`, `>`. +| `->` | arrow | Step / projection arrow in `bind -> res` and phase transitions `RAW -> COOKED`. Two scalars; munched before `-`. +| `..` | range | Envelope range `0C .. 250C`, `0Nm .. 8Nm` (constitution §2.4 `range`/`slope`/`reach`). Two scalars; munched before `.`. +| `>=` | ge | Comparison in conditions. Two scalars; before `>`. +| `<=` | le | Comparison. Two scalars; before `<`. +| `==` | eq | Echo-witness equality `@white_set == true`. Two scalars; before `=`. +| `!=` | ne | Comparison. Two scalars. +| `>` | gt | Comparison. +| `<` | lt | Comparison. +| `~` | tropical | Tropical setpoint/threshold sigil (<>). +| `@` | echo (bare) | Bare echo sigil when not fused (<>). +| `.` | member | `actor.Verb`, `Delia_Hob.visual` member access. +| `:` | colon | Key/value in `arg ::= ident ":" …`, envelope fields `curdle: 82C`, and modality `on Hob.visual`. +| `;` | semicolon | Statement terminator. +| `,` | comma | Separator in actor lists, arg lists, bind tuples, ceremony settings. +| `(` `)` | parens | Grouping: actor lists, param lists, bind tuples. +| `{` `}` | braces | Block delimiters: envelopes, sync bodies, recipe bodies, ceremonies. +|=== + +The comparison set stem:[\{ ==, !=, >=, <=, >, < \}] is the v1.0 `comparison` +placeholder (O3) resolved. `=` *alone* is *not* a token (KitchenSpeak has no +assignment); a lone `=` not part of `==`, `>=`, `<=`, or `!=` is `E-OP`. + +== 10. Maximal munch and the master automaton [[munch]] + +=== 10.1 The rule + +KitchenSpeak tokenisation obeys *maximal munch* (longest match): at each +position the lexer consumes the longest scalar run that forms a valid token of +*any* class. Ties (two classes accepting the same longest run) are broken by the +fixed class priority below. After emitting a token, the lexer resumes at the next +unconsumed scalar. + +.Class priority for tie-breaking (highest first) +. Comment openers `//`, `/*` (consume to terminator before any operator). +. Compound operators `<~>`, `->`, `..`, `>=`, `<=`, `==`, `!=`. +. `QTY` / `NUM` (a magnitude greedily extends through its longest table suffix). +. `ECHO_REF` (`@` fused with a following identifier). +. `IDENT`-shaped run → reclassified by <> (`KEYWORD` / `SLOPE_TAG` / `STYLE_TAG` / `FAIL_TAG` / `PHASE_TAG` / `IDENT`). +. Single-scalar operators and punctuation. + +=== 10.2 Worked munch cases + +[cols="1,2,3", options="header"] +|=== +| Input fragment | Tokens | Why + +| `<~>` | `OP("<~>")` | Compound beats `<`,`~`,`>` (priority 2). +| `<~ x` | `OP("<")` `OP("~")` `IDENT("x")` | `<~>` fails (no `>`), so `<` then `~` then ident. +| `2C/s` | `QTY(2 °C/s)` | Suffix automaton munches `C/s` atomically (priority 3). +| `2C / s` | `QTY(2 °C)` `OP("/")`? — `/` is *not* an operator; this is `E-OP` | KitchenSpeak has no bare `/` operator; division appears only inside unit suffixes. A standalone `/` (not opening a comment, not in a suffix) is `E-OP`. +| `0.8Pa·s` | `QTY(0.8 Pa·s)` | Magnitude `0.8`, suffix `Pa·s` (the `·` consumed as unit separator). +| `r..5` | `IDENT("r")` `OP("..")` `NUM(5)` | `..` beats `.` `.`. +| `a.b` | `IDENT("a")` `OP(".")` `IDENT("b")` | Single `.` member access; no `..` because only one dot. +| `@white_set==true` | `ECHO_REF("white_set")` `OP("==")` `KEYWORD("true")` | `@`+ident fused; `==` compound; `true` reserved. +| `80rpm` | `QTY(8.3776 rad/s)` | `rpm` munched over `r`,`p`,`m`; normalised by stem:[2\pi/60]. +| `min` | `IDENT("min")` | `min` is *not* a keyword and not adjacent to a magnitude; bare ident. +| `5min` | `QTY(300 s)` | Adjacent magnitude+suffix. +|=== + +== 11. Two fully worked tokenisations + +=== 11.1 Line from `examples/poached-egg.ks` + +Source line: + +[source] +---- +Delia_Hob.Heat(target: 82C, slope: GENTLE) +---- + +Token stream (class, lexeme, value): + +[cols="1,1,3", options="header"] +|=== +| # | Lexeme | Token + +| 1 | `Delia_Hob` | `IDENT`, value `"Delia_Hob"` +| 2 | `.` | `OP`, value `"."` (member access) +| 3 | `Heat` | `IDENT`, value `"Heat"` (capability verb) +| 4 | `(` | `OP`, value `"("` +| 5 | `target` | `IDENT`, value `"target"` (arg key) +| 6 | `:` | `OP`, value `":"` +| 7 | `82C` | `QTY`, value stem:[(82,\ \texttt{Temperature})] = 82 °C +| 8 | `,` | `OP`, value `","` +| 9 | `slope` | `KEYWORD`, value `slope` +| 10 | `:` | `OP`, value `":"` +| 11 | `GENTLE` | `SLOPE_TAG`, value stem:[0.30] (× appliance max slope) +| 12 | `)` | `OP`, value `")"` +|=== + +Note token 9: `slope` is a *keyword* (envelope field, §4) here, whereas `target` +(token 5) is a plain `IDENT` argument key — the asymmetry is real and intended. + +=== 11.2 Line from `proofs/agda/Dough.agda`'s source program + +Source line (the `Knead` step's `until` clause and budget): + +[source] +---- +Knead(RPM: 80) until @viscosity >= 0.8Nm max_duration 15min +---- + +Token stream: + +[cols="1,1,3", options="header"] +|=== +| # | Lexeme | Token + +| 1 | `Knead` | `IDENT`, `"Knead"` (verb) +| 2 | `(` | `OP`, `"("` +| 3 | `RPM` | `IDENT`, `"RPM"` (arg key) +| 4 | `:` | `OP`, `":"` +| 5 | `80` | `NUM`, value stem:[(80,\ \texttt{Dimensionless})] — bare, no suffix +| 6 | `)` | `OP`, `")"` +| 7 | `until` | `KEYWORD`, `until` +| 8 | `@viscosity` | `ECHO_REF`, value `"viscosity"` +| 9 | `>=` | `OP`, `">="` +| 10 | `0.8Nm` | `QTY`, value stem:[(0.8,\ \texttt{Torque})] = 0.8 N·m +| 11 | `max_duration` | `KEYWORD`, `max_duration` +| 12 | `15min` | `QTY`, value stem:[(900,\ \texttt{Time})] = 900 s (15 × 60) +|=== + +Note token 5: `80` in `RPM: 80` is *dimensionless* — the dimension lives in the +arg key `RPM`, not a suffix — so it is `NUM`, contrasting with token 10's `0.8Nm` +which carries its `Torque` dimension in the suffix. This is exactly the v1.0 vs +v2.0 surface tension the constitution §1 describes: v1.0 wrote bare numbers with +units in the key; v2.0 prefers dimensioned literals. Both lex; the type checker +reconciles the key's expected dimension against the literal's. + +== 12. Resolution of O3 placeholders [[placeholder-map]] + +This section discharges open item O3 by mapping each `grammar.ebnf` lexical +placeholder to a token class defined above. + +[cols="1,2,3", options="header"] +|=== +| O3 placeholder | Token class(es) | Note + +| `identifier` | `IDENT` | §5. +| `integer` | `NUM` with empty `frac_part` | §6.1. The lexer does not distinguish int from real; a downstream check enforces integrality where required (e.g. `alert_level: 4`). +| `actor`, `actor_id` | `IDENT` | A declared chef/appliance name. +| `actors` | `IDENT { "," IDENT }` | Comma list (parser-level), each an `IDENT`. +| `action` | `IDENT` | The capability verb after `.`. +| `params` | sequence of `arg` (parser-level) | Each `arg` = `IDENT ":" (QTY \| NUM \| SLOPE_TAG \| STYLE_TAG \| IDENT)`. +| `metric` | `IDENT` | The quantity name after `~`. +| `comparison` | `OP` ∈ {`==`,`!=`,`>=`,`<=`,`>`,`<`} | §9. +| `value` | `QTY` \| `NUM` \| `KEYWORD(true/false)` \| `ECHO_REF` | §6, §4. +| `time` | `QTY` with dimension `Time` | A typed `max_duration`; the checker rejects a non-`Time` `QTY` here. +| `error_handle` | `FAIL_TAG` ∈ {`ABORT`,`RECOVER`,`WARM`} | §7.3. +| `ceremony_id`, `echo_id`, `resource_id` | `IDENT` (echo references use `ECHO_REF`) | `echo_id` after `proving` is `ECHO_REF`; a ceremony or resource name is `IDENT`. +|=== + +== 13. Lexical error catalogue (normative) + +[cols="1,3", options="header"] +|=== +| Code | Condition + +| `E-ENCODING` | Ill-formed UTF-8, or a U+FEFF not at file start. +| `E-UNICODE` | A non-ASCII scalar outside the three admitted roles (§1.2), in token position. +| `E-COMMENT` | Unterminated block comment at EOF. +| `E-NUMBER` | A magnitude with `.` and no fractional digit (`5.`). +| `E-UNIT` | A magnitude butting against an unrecognised unit/letter run (`82x`, `82Q`). +| `E-OP` | An illegal operator scalar (lone `=`, bare `/` outside a unit suffix or comment). +| `E-CHAR` | Any scalar that begins no token class at all. +|=== + +Every lexical error carries the byte offset and the offending lexeme, so a +mis-typed unit (the most safety-relevant lexical mistake — a wrong unit is how +you scorch an egg) surfaces with a precise location *before* any type checking +runs. The lexer is the first line of the "make illegal physical states +unrepresentable" defence: a quantity whose unit the lexer cannot place never +reaches the safety judgment of constitution §5 as a silent dimensionless number. diff --git a/docs/spec/0002-concrete-grammar.adoc b/docs/spec/0002-concrete-grammar.adoc new file mode 100644 index 0000000..d9bf4cb --- /dev/null +++ b/docs/spec/0002-concrete-grammar.adoc @@ -0,0 +1,966 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — The Complete Concrete Grammar (EBNF) +:toc: +:toclevels: 3 +:icons: font +:stem: latexmath + +[NOTE] +==== +This document is the *normative concrete grammar* for KitchenSpeak v2.0. It +elaborates the binding constitution (`docs/design/0001-v2-constitution.adoc`) +and the class's `SPEC.adoc` v1.0. It *adds precision* and *closes open items*; +it does not contradict the constitution. Where the constitution gives a sketch +production (e.g. §2.6 of the constitution's `recipe`/`stmt`/`step` sketch in +its §3), this document supplies the full, LL(k)/recursive-descent-friendly +form, and any difference is a *refinement marked as such*, never a silent +divergence. + +Provenance of every production is tagged in the right margin of the listings: + +[cols="1,5", options="header"] +|=== +| Tag | Meaning +| `[A]` | *Inherited from v1.0* (`grammar.ebnf` Section A), unchanged. +| `[A/P]` | *v1.0 patch* (`grammar.ebnf` Section B), carried into v2.0. +| `[N]` | *NEW in v2.0* — introduced to carry the constitution's physics-in-types. +| `[R]` | *Refined* — a v1.0 production whose body is widened/tightened for v2.0, backwards-compatibly. +|=== + +*Backwards-compatibility theorem (informal).* Every well-typed v1.0 program is +a well-typed v2.0 program. The grammar guarantees the syntactic half: every +v1.0 production is either inherited verbatim `[A]`/`[A/P]` or refined `[R]` by +*adding alternatives and optional clauses only*, never by removing a v1.0 +form. `orchestrate` is retained as a deprecated synonym for `recipe` +(§<>), so a v1.0 file parses unchanged. The semantic half (stdlib +supplies materials/appliances) is the constitution's §0 promise and is out of +scope here. +==== + +== 1. Notation and conventions + +This grammar is written in ISO/IEC 14977 EBNF with the house relaxations used +in `grammar.ebnf`: + +[cols="1,4", options="header"] +|=== +| Form | Meaning +| `"lit"` | terminal literal (keyword, punctuation, operator) +| `name` | nonterminal +| `{ x }` | zero or more `x` +| `[ x ]` | optional `x` (zero or one) +| `x \| y` | alternation +| `( x )` | grouping +| `(* … *)` | comment +| `?regex?` | a lexical class defined by the regular expression (lexer layer) +|=== + +*Design contract for the parser.* The whole grammar is *LL(2)*: at most two +tokens of lookahead disambiguate every production. The two places that need a +second token are called out in §<> (the `recipe`-body statement +dispatch, and the `(` that begins either a `bind_step` or a parenthesised +sub-expression). Maximal-munch governs the lexer (longest token wins: +`max_duration` is one keyword, not `max` `_` `duration`); explicit operator +precedence governs the expression grammar (§<>). + +== 2. Lexical grammar (closing O3) + +v1.0 deferred the lexer entirely (`COMMENTARY.adoc` "Deliberately out of +scope"; `grammar.ebnf` open item *O3*). v2.0 needs a real lexer because the +type checker reads *dimensions off literals* (constitution §2.1). This section +is therefore `[N]`, except where it merely names a v1.0 placeholder. + +[source,ebnf] +---- +(* --- whitespace & comments: not tokens, discarded by the lexer --- *) (* [N] *) +ws = ?[ \t\r\n]+? ; +line_comment = "//", ?[^\n]*? ; +block_comment= "/*", ?.*?, "*/" ; (* non-greedy *) + +(* --- identifiers & names (were O3 placeholders in v1.0) --- *) +identifier = ?[A-Za-z_][A-Za-z0-9_]*? ; (* [A/P] now concrete *) +type_name = ?[A-Z][A-Za-z0-9_]*? ; (* Material/Tool/Appliance names *) (* [N] *) +echo_id = "@", identifier ; (* [A/P] now concrete *) +ceremony_id = identifier ; (* [A] *) +resource_id = identifier ; (* [A/P] *) +actor_id = identifier ; (* [A/P] *) +actor = identifier ; (* [A] *) +Verb = type_name ; (* capability verbs are PascalCase: Heat, Move *) (* [R] *) +metric = identifier ; (* temp, torque, viscosity, rpm *) (* [A] *) + +(* --- numeric & dimensioned literals (NEW: carry the dimension) --- *) (* [N] *) +integer = ?-?[0-9]+? ; (* [A] now concrete *) +decimal = ?-?[0-9]+(\.[0-9]+)? ; +number = decimal ; + +qty = number, unit ; (* a dimensioned quantity literal *) +unit = base_unit | compound_unit ; +base_unit = "C" | "K" (* Temperature *) + | "s" | "min" | "h" (* Time *) + | "g" | "kg" | "mg" (* Mass *) + | "Nm" (* Torque *) + | "rpm" (* AngularRate *) + | "mm" | "cm" | "m" (* Length *) + | "Pa·s"| "Pas" (* Viscosity; ASCII fallback Pas *) + | "W" | "kW" (* Power *) + | "%" | "" (* Dimensionless (empty unit) *) ; +compound_unit= base_unit, ( "·" | "*" ), base_unit (* e.g. N·m *) + | base_unit, "/", base_unit ; (* e.g. C/s, 2C/s *) +---- + +[NOTE] +==== +*Maximal munch on units.* `2C/s` lexes as one `qty` token with +`compound_unit = "C" "/" "s"`, *not* as `2C`, `/`, `s`. The lexer reads the +longest valid `unit` immediately following a `number`. `82C` is `qty(82, +Temperature)`; `8min` is `qty(8, Time)`; `0.8Pa·s` is `qty(0.8, Viscosity)`. +This is the lexical anchor for the constitution's §2.1 promise that "literals +carry a unit suffix that fixes the dimension." +==== + +The reserved keywords (never identifiers): + +[source,ebnf] +---- +keyword = "material" | "tool" | "appliance" | "capability" (* [N] *) + | "thermal" | "mechanical" | "chemical" | "phase" + | "quantity" | "range" | "slope" | "reach" | "jitter" + | "soft" | "hard" + | "resource" | "chef" | "echo" | "ceremony" (* [A/P]/[N] *) + | "controls" | "style" | "on" | "as" | "use" (* [N] *) + | "recipe" | "orchestrate" | "under" | "import" (* [R]/[N] *) + | "sync" | "proving" (* [A] *) + | "until" | "max_duration" | "on_fail" (* [A] *) + | "ABORT" | "RECOVER" | "WARM" ; (* [A] *) +---- + +== 3. Top level (closing O2: the missing declarations) + +v1.0 named `resource_decl`, `chef_decl`, `echo_decl` but never defined them +(`grammar.ebnf` open item *O2*; `COMMENTARY.adoc` left their shape to the +class). v2.0 defines all three, and adds the three new keystone declarations +the constitution mandates (`material`, `tool`, `appliance` — constitution +§2.2–§2.4) plus `use_decl` (constitution §2.5). + +[source,ebnf] +---- +program = { import_decl }, { decl }, recipe, { recipe } ; (* [R] *) + (* v1.0: program = {decl}, orchestrate{...}. + Refinement: 0..n declarations, then 1..n recipes. + A lone v1.0 `orchestrate{...}` with its decls still parses, + because `orchestrate` is a recipe synonym (§ recipe). *) + +import_decl = "import", string_lit, [ "as", identifier ], ";" ; (* [N] -- use_decl/library bootstrap, §3.6 *) + +decl = material_decl (* [N] *) + | tool_decl (* [N] *) + | appliance_decl (* [N] *) + | resource_decl (* [A/P]->[N] closes O2 *) + | chef_decl (* closes O2 *) + | echo_decl (* closes O2 *) + | ceremony_decl ; (* [A/P] *) +---- + +The seven kinds of top-level declaration (plus `import`) line up one-to-one +with the constitution's "Eight kinds of declaration, plus the mission body" +(§2). The mission body is `recipe` (§<>). + +=== 3.1 `material_decl` — the safety envelope `[N]` + +Directly encodes constitution §2.2. + +[source,ebnf] +---- +material_decl = "material", type_name, "{", + phase_clause, + { envelope_block }, + "}" ; (* [N] *) + +phase_clause = "phase", ":", phase_state, [ phase_arrows ] ; +phase_state = type_name ; (* LIQUID, RAW, SET, COOKED, ... *) +phase_arrows = "transitions", "{", phase_edge, { ",", phase_edge }, "}" ; +phase_edge = phase_state, "->", phase_state, [ "ruin" ] ; + (* `ruin` marks an absorbing failure state: CURDLED, SCORCHED *) + +envelope_block= thermal_block | mechanical_block | chemical_block ; +thermal_block = "thermal", "{", { limit }, "}" ; +mechanical_block= "mechanical","{", { limit }, "}" ; +chemical_block= "chemical", "{", { chem_limit }, "}" ; + +limit = [ limit_kind ], identifier, ":", qty ; +limit_kind = "soft" | "hard" ; + (* default when omitted: scald/freeze-style names => soft; + curdle/melt/char/burn names => hard. The checker fixes the + default by a name table; explicit soft/hard always wins. + See § Ambiguity for why the keyword is leading, not trailing. *) + +chem_limit = "pH", ":", number, "..", number (* pH band *) + | "incompatible", ":", "{", type_name, { ",", type_name }, "}" ; +---- + +.Worked declaration (the curdle-danger keystone) +[source] +---- +material Milk { + phase: LIQUID transitions { LIQUID -> SET, LIQUID -> CURDLED ruin } + thermal { soft scald: 70C + hard curdle: 82C // the danger limit + soft freeze: 0C } +} +---- + +=== 3.2 `tool_decl` — inert implement with material limits `[N]` + +Constitution §2.3: a tool is a material with a `material:` tag. + +[source,ebnf] +---- +tool_decl = "tool", type_name, "{", + "material", ":", type_name, (* SILICONE, STEEL, ... *) + { envelope_block }, + "}" ; (* [N] *) +---- + +.Worked declaration ("bake the spatula" → compile error source) +[source] +---- +tool Spatula { material: SILICONE thermal { hard melt: 260C hard char: 320C } } +---- + +=== 3.3 `appliance_decl` — the capability envelope `[N]` + +Constitution §2.4. Each `capability` is a verb plus its physical envelope. The +optional `slope`, `reach`, and `jitter` sub-clauses carry the trajectory and +geometric bounds the physical-safety judgment (constitution §5) intersects +against material limits. + +[source,ebnf] +---- +appliance_decl= "appliance", type_name, "{", + capability_block, { capability_block }, + "}" ; (* [N] *) + +capability_block = "capability", Verb, "{", + "quantity", ":", dimension, + "range", ":", qty, "..", qty, + [ "slope", ":", qty, "..", qty ], + [ "reach", ":", qty, "..", qty ], + [ "jitter", ":", qty ], + "}" ; + +dimension = "Temperature" | "Time" | "Mass" | "Torque" | "AngularRate" (* [N] *) + | "Length" | "Viscosity" | "Power" | "Dimensionless" + | dimension, ( "·" | "*" ), dimension + | dimension, "/", dimension ; +---- + +.Worked declarations (Hob, Oven, robot Arm — verbatim from constitution §2.4) +[source] +---- +appliance Hob { capability Heat { quantity: Temperature range: 0C .. 250C slope: 0C/s .. 5C/s } } +appliance Oven { capability Bake { quantity: Temperature range: 50C .. 300C } } +appliance Arm { + capability Move { quantity: Torque range: 0Nm .. 8Nm reach: 0cm .. 85cm } + capability Whisk { quantity: AngularRate range: 0rpm .. 1200rpm } +} +---- + +=== 3.4 `resource_decl`, `chef_decl`, `echo_decl` — closing O2 + +These three were named-but-undefined in v1.0. Their shapes are fixed here to +match the worked examples (`examples/poached-egg.ks` uses all three) and the +constitution §2.5. + +[source,ebnf] +---- +resource_decl= "resource", resource_id, "state", phase_state, (* O2: now concrete *) + [ "of", type_name ], ";" ; + (* v1.0 form: `resource Egg state RAW;` — kept exactly. + v2.0 OPTIONAL `of Material` ties the linear resource to a + declared material's safety envelope. When omitted, the + resource is untyped-material (v1.0 compatibility): the + checker treats its danger set as empty (no envelope to + protect), exactly reproducing v1.0 behaviour. *) + +chef_decl = "chef", actor_id, (* O2: now concrete *) + ( "style", style_tag (* v1.0 short form *) + | "controls", type_name, { ",", type_name }, (* v2.0 controls *) + [ "style", style_tag ] ), + ";" ; +style_tag = "PRECISION" | "AGGRESSIVE" | "GENTLE" | identifier ; + (* style may only SHRINK envelopes (constitution §2.5); + enforced by the checker, not the grammar. *) + +echo_decl = "echo", echo_id, "on", actor_id, ".", modality, ";" ; (* O2: now concrete *) +modality = "visual" | "acoustic" | "thermal" | identifier ; + (* `echo @white_set on Delia_Hob.visual;` (poached-egg.ks) *) +---- + +`chef_decl` is `[R]`: it admits *both* the v1.0 short form (`chef Delia_Hob +style PRECISION;`, used verbatim in `examples/poached-egg.ks`) *and* the v2.0 +`controls` form (constitution §2.5: `chef Delia controls Hob style +PRECISION;`). LL(2) lookahead on the token after `actor_id` (`style` vs +`controls`) selects the arm. + +=== 3.5 `ceremony_decl` — inherited patch `[A/P]` + +Carried unchanged from `grammar.ebnf` Patch 1. + +[source,ebnf] +---- +ceremony_decl = "ceremony", ceremony_id, "{", (* [A/P] *) + ceremony_setting, { ",", ceremony_setting }, + "}" ; +ceremony_setting = setting_key, ":", setting_value ; +setting_key = identifier ; +setting_value = identifier | integer ; +---- + +[[recipe]] +=== 3.6 `recipe` — the mission body, renaming/extending `orchestrate` `[R]` + +Constitution §3 renames `orchestrate` to `recipe` and extends it. v1.0's +`orchestrate` is retained as a *deprecated synonym* so v1.0 files parse +unchanged (backwards-compatibility, constitution §9). + +[source,ebnf] +---- +recipe = recipe_kw, identifier, [ "under", ceremony_id ], "{", (* [R] *) + { use_decl }, (* mission-scoped linear resources *) + { stmt }, + "}" ; +recipe_kw = "recipe" | "orchestrate" ; (* `orchestrate` = deprecated synonym *) + +stmt = sync_block | bind_step | use_decl | resource_decl ; (* [R]; was `block` *) +---- + +`stmt` widens v1.0's `block` (`grammar.ebnf` consolidated `block' = sync_block +| bind_step | resource_decl`) by *adding* the `use_decl` alternative `[N]`; +every prior alternative is retained, so every v1.0 mission body still parses. + +`use_decl` is NEW (constitution §2.5: "`use Milk as m;`"): it brings a linear +*instance* of a declared material into the mission. It is permitted both as a +top-of-body header *and* interleaved as a `stmt` (mirroring how v1.0 allowed +in-body `resource_decl` via Patch 3). + +[source,ebnf] +---- +use_decl = "use", type_name, "as", resource_id, (* [N] *) + [ "qty", qty ], ";" ; + (* `use Milk as m;` or `use Milk as m qty 250g;` *) +---- + +== 4. The step and the sync block (closing O1) + +=== 4.1 The extended step `[R]` + +Constitution §3 extends the v1.0 `step` with a *target argument* and *named +envelope args*. v1.0's `step` was `actor "." action "(" params ")" until … +max_duration … on_fail …;` with `params` an undefined placeholder. v2.0 fixes +`params` as `target [, arg]*` where each `arg` is `name : value`. + +[source,ebnf] +---- +step = actor, ".", Verb, "(", step_args, ")", (* [R] *) + "until", condition, + "max_duration", qty, + "on_fail", error_handle, ";" ; + +step_args = [ target [ "," arg_list ] ] ; (* target first, then named args *) +target = resource_id | qty | type_name ; (* the thing acted on *) +arg_list = arg, { ",", arg } ; +arg = identifier, ":", arg_value ; (* NAMED args only after target *) +arg_value = qty | slope_tag | resource_id | type_name | style_tag ; +slope_tag = "GENTLE" | "STEADY" | "AGGRESSIVE" | qty ; + (* slope: GENTLE (a named slope class) OR slope: 2C/s (an explicit rate) *) + +error_handle = "ABORT" | "RECOVER" | "WARM" ; (* [A] *) +---- + +[IMPORTANT] +==== +*The three production-mandatory constructs are syntactically non-optional.* +`until`, `max_duration`, and `on_fail` each appear *without* surrounding `[ ]` +in the `step` production. A step missing any of the three is a *parse* error +(and, per the constitution §3 and `COMMENTARY.adoc`, a type error: they are +the termination witness, the total failure handler, and the postcondition). +This is exactly the v1.0 contract, preserved. +==== + +The constitution §3 sketch writes `step ::= actor "." Verb "(" target [, arg]* +")"`. This grammar *refines* that sketch by (a) making `target` optional so a +zero-target capability (e.g. a pure `Heat` of the appliance's own medium) is +expressible, and (b) fixing `arg` to the `name : value` named-envelope form. A +v1.0 `Heat(target: 82C, slope: GENTLE)` (from `examples/poached-egg.ks`) +parses as `target` absent, `arg_list = [target:82C, slope:GENTLE]` — note the +v1.0 example uses `target:` as a *named arg*, which remains valid because +`arg.identifier` may literally be `target`. New v2.0 code may instead write +the egg as a positional `target`: `Poach(Egg, acidulant: Vinegar, depth: +4cm)`. Both forms parse; see worked parse 1. + +=== 4.2 `condition` — the `until` postcondition `[R]` + +[source,ebnf] +---- +condition = tropical_threshold | echo_witness ; (* [A] *) +tropical_threshold = "~", metric, comparison, value ; (* [A] *) +echo_witness = echo_id, comparison, value ; (* [R] *) + (* v1.0: "@", metric, cmp, value. + Refinement: echo_id already includes the leading "@", + so `@white_set == true` parses as echo_witness with + echo_id=@white_set. Reproduces v1.0 exactly. *) +comparison = ">=" | "<=" | ">" | "<" | "==" | "!=" ; (* [A] now concrete *) +value = qty | boolean | type_name | number ; (* [A] now concrete *) +boolean = "true" | "false" ; +---- + +[[expr]] +The expression sub-language is intentionally tiny (no infix arithmetic in +conditions): a `condition` is exactly one comparison. This keeps the grammar +LL(2) with no precedence climbing required inside `until`. Dimensioned +arithmetic (constitution §2.1, "arithmetic preserves dimension") lives in +`qty` literal normalisation at the lexer/checker, not in a condition +expression grammar — a deliberate scope boundary that keeps the parser simple. + +=== 4.3 `sync_block` and `bind_step` — closing O1 (nesting) + +v1.0 (`grammar.ebnf` open item *O1*) forbade nested `sync_block`/`bind_step` +inside a `sync` body: `step` was the only thing a `sync` could contain. v2.0 +*closes O1* by letting a sync body contain a `sync_item`, which is a `step`, a +*nested* `sync_block`, or a `bind_step`. + +[source,ebnf] +---- +sync_block = "sync", "(", actors, ")", [ "proving", echo_id ], "{", (* [R] closes O1 *) + sync_item, { sync_item }, + "}" ; +sync_item = step | sync_block | bind_step ; (* O1: nesting now allowed *) +actors = actor, { ",", actor } ; (* [A] now concrete *) + +bind_step = "(", resource_id, ",", resource_id, { ",", resource_id }, ")", (* [A/P] *) + "<~>", actor_id, [ "->", resource_id ], ";" ; + (* v1.0 Patch 2: `(Flour, Water) <~> Baridi_Robot;` + v2.0 OPTIONAL `-> resource_id` names the product resource, + matching constitution §3's bind sketch + `( res, res+ ) <~> actor -> res ;`. When omitted, the + product is anonymous (v1.0 Dough behaviour). *) +---- + +==== O1 resolution: what nesting *means* + +A nested `sync_block` inside an outer `sync(A,B){ … }` body denotes a +*sub-session*: a choreographic sub-barrier among a *subset* (or the same set) +of the enclosing actors, executed in sequence at the point it appears in the +parent body. Operationally and type-theoretically (per `COMMENTARY.adoc` +§Choreographic, multiparty session types): + +. *Actor scoping.* The actor set of an inner `sync` must be a *subset* of the + enclosing actor set (`actors_inner ⊆ actors_outer`). An actor named inside + but not introduced outside is a type error (no projection exists for it in + the parent choreography). The grammar permits any `actors`; the checker + enforces the subset law. + +. *Sequencing & barrier nesting.* The inner barrier opens and closes *within* + the outer barrier's lifetime: the outer barrier is released only after every + `sync_item` in its body — including completed inner sub-sessions — has + terminated (each via its own `success ⊎ {aborted, recovered, warmed}` + image). Deadlock-freedom of the whole is the conjunction of deadlock-freedom + of each level; because nesting is *lexical* (a tree, not arbitrary + cross-edges), no new deadlock cycles are introducible — this is precisely + why nesting is safe to admit (it was deferred in v1.0 only for teaching + simplicity, `COMMENTARY.adoc` "Deliberately out of scope", not because it + was unsound). + +. *Witness scoping.* A `proving @e` on the inner block introduces an + existential witness scoped to the inner block; it does *not* discharge the + outer block's `proving`. Each level carries its own witness obligation + (constitution §5 "Witness"). + +. *Linearity across levels.* A linear resource consumed by an inner step has + *left the linear context* for the remainder of the outer body. A `bind_step` + nested inside a `sync` consumes its factors at that point and (optionally) + introduces the named product into the enclosing scope. This is the + constitution's "binding is algebra; sync is physics" placed under a physical + barrier when — and only when — the bind must be *coordinated* with a device + action (e.g. the robot must hold the bowl while binding). A *top-level* + `bind_step` (a `stmt`) remains the pure-algebra form from v1.0 Patch 2. + +.Illustrative nested sync (robot arm + hob coordinating a sub-session) +[source] +---- +sync(Arm, Hob) proving @plated { + Hob.Hold(Pan) until ~ temp <= 60C max_duration 30s on_fail WARM; + sync(Arm) proving @placed { // nested sub-session + Arm.Move(Egg, torque: 6Nm, reach: 40cm) + until @placed == true + max_duration 20s + on_fail RECOVER; + } +} +---- + +== 5. The whole grammar shows the constitution's §2 and §3 + +[cols="1,4", options="header"] +|=== +| Constitution clause | Production(s) that express it +| §2.1 Quantity & Dimension | `qty`, `unit`, `base_unit`, `compound_unit`, `dimension` +| §2.2 Material (safety envelope) | `material_decl`, `phase_clause`, `thermal_block`, `mechanical_block`, `chemical_block`, `limit`, `limit_kind` +| §2.3 Tool | `tool_decl` +| §2.4 Appliance (capability envelope) | `appliance_decl`, `capability_block` (`quantity`/`range`/`slope`/`reach`/`jitter`) +| §2.5 Resource / Chef / Echo / Ceremony | `use_decl`+`resource_decl`; `chef_decl` (`controls`/`style`); `echo_decl` (`on actor.modality`); `ceremony_decl` +| §3 Mission body | `recipe` (`under `) +| §3 `stmt` | `stmt = sync_block \| bind_step \| use_decl \| resource_decl` +| §3 `sync` | `sync_block` (+ O1 nesting via `sync_item`) +| §3 `bind` (dyadic `<~>`) | `bind_step` (+ optional `-> res`) +| §3 `step` (target + named args) | `step`, `step_args`, `target`, `arg`, `arg_value` +| §3 `cond` | `condition`, `tropical_threshold` (`~`), `echo_witness` (`@`) +| §3 mandatory `until`/`max_duration`/`on_fail` | non-optional clauses in `step` +|=== + +The physical-safety judgment (constitution §5) is a *type-checker* obligation, +not a grammar one; but the grammar *carries every input the judgment needs*: +`capability_block` supplies `cap`, `arg`/`step_args` supply the requested +envelope `req`, `chef_decl` supplies the style modifier, and +`material_decl`/`tool_decl` supply `danger(m)`. The grammar therefore makes +the constitution §5 rule *checkable* — every premise has a concrete syntactic +source. + +== 6. Three full worked parses + +=== 6.1 The poached-egg port + +Concrete syntax (v2.0 port of `examples/poached-egg.ks`; the egg becomes a +positional `target`, the materials are declared, the chef uses `controls`): + +[source] +---- +material Water { phase: LIQUID thermal { soft scald: 95C hard boil: 100C } } +material Egg { phase: RAW transitions { RAW -> POACHED } } +material Vinegar { phase: LIQUID } +appliance Hob { capability Heat { quantity: Temperature range: 0C .. 250C slope: 0C/s .. 5C/s } + capability Poach { quantity: Temperature range: 0C .. 100C } } +chef Delia_Hob controls Hob style PRECISION; +echo @water_shimmer on Delia_Hob.visual; +echo @white_set on Delia_Hob.visual; +ceremony Breakfast_Brisk { pace: BRISK, alert_level: 4, priority: TIMING } + +recipe PoachedEgg under Breakfast_Brisk { + use Vinegar as vin; + sync(Delia_Hob) proving @water_shimmer { + Delia_Hob.Heat(target: 82C, slope: GENTLE) + until ~ temp >= 80C + max_duration 8min + on_fail ABORT; + } + sync(Delia_Hob) proving @white_set { + Delia_Hob.Poach(Egg, acidulant: vin, depth: 4cm) + until @white_set == true + max_duration 4min + on_fail RECOVER; + } +} +---- + +Parse-tree sketch (recipe body only; declarations elide to their nonterminals): + +---- +program +├─ decl: material_decl Water { phase:LIQUID; thermal[soft scald:82C? -> qty(95,Temp); hard boil:qty(100,Temp)] } +├─ decl: material_decl Egg { phase:RAW; transitions[RAW->POACHED] } +├─ decl: material_decl Vinegar { phase:LIQUID } +├─ decl: appliance_decl Hob { cap Heat{Temp,0..250C,slope 0..5C/s}; cap Poach{Temp,0..100C} } +├─ decl: chef_decl Delia_Hob controls Hob style PRECISION +├─ decl: echo_decl @water_shimmer on Delia_Hob.visual +├─ decl: echo_decl @white_set on Delia_Hob.visual +├─ decl: ceremony_decl Breakfast_Brisk { pace:BRISK, alert_level:4, priority:TIMING } +└─ recipe "PoachedEgg" under Breakfast_Brisk + ├─ use_decl: use Vinegar as vin + ├─ stmt → sync_block + │ ├─ actors = [Delia_Hob] + │ ├─ proving = @water_shimmer + │ └─ sync_item → step + │ ├─ actor=Delia_Hob Verb=Heat + │ ├─ step_args: target=∅; arg_list=[ arg(target : qty(82,Temp)), + │ │ arg(slope : slope_tag GENTLE) ] + │ ├─ until = tropical_threshold( ~ metric(temp) >= qty(80,Temp) ) + │ ├─ max_duration = qty(8,Time) + │ └─ on_fail = ABORT + └─ stmt → sync_block + ├─ actors = [Delia_Hob] + ├─ proving = @white_set + └─ sync_item → step + ├─ actor=Delia_Hob Verb=Poach + ├─ step_args: target = resource_id(Egg); + │ arg_list=[ arg(acidulant : resource_id vin), + │ arg(depth : qty(4,Length)) ] + ├─ until = echo_witness( @white_set == boolean(true) ) + ├─ max_duration = qty(4,Time) + └─ on_fail = RECOVER +---- + +Note the *two valid argument styles in one program*: `Heat` uses the v1.0 +named-arg style (`target:` is just an `arg` whose name happens to be +`target`), while `Poach` uses the v2.0 positional `target` (`Egg`) followed by +named args. Both reduce through `step_args`. + +=== 6.2 A scald-milk recipe (the constitution §7 flagship) + +Concrete syntax — the recipe `NoCurdle.agda` proves; deliberately stays +strictly below Milk's `hard curdle: 82C`: + +[source] +---- +material Milk { + phase: LIQUID transitions { LIQUID -> SET, LIQUID -> CURDLED ruin } + thermal { soft scald: 70C hard curdle: 82C soft freeze: 0C } +} +appliance Hob { capability Heat { quantity: Temperature range: 0C .. 250C slope: 0C/s .. 5C/s } } +chef Jamie_Hob controls Hob style GENTLE; +echo @scald_skin on Jamie_Hob.visual; + +recipe ScaldMilk { + use Milk as m qty 250g; + sync(Jamie_Hob) proving @scald_skin { + Jamie_Hob.Heat(m, target: 75C, slope: GENTLE) + until ~ temp >= 68C + max_duration 6min + on_fail ABORT; + } +} +---- + +Parse-tree sketch: + +---- +program +├─ decl: material_decl Milk +│ ├─ phase_clause: phase:LIQUID +│ │ └─ phase_arrows: [ LIQUID->SET, LIQUID->CURDLED ruin ] +│ └─ thermal_block: [ limit(soft, scald, qty(70,Temp)), +│ limit(hard, curdle, qty(82,Temp)), ◀ danger set = {curdle@82C} +│ limit(soft, freeze, qty(0,Temp)) ] +├─ decl: appliance_decl Hob { cap Heat{Temp, 0..250C, slope 0..5C/s} } +├─ decl: chef_decl Jamie_Hob controls Hob style GENTLE +├─ decl: echo_decl @scald_skin on Jamie_Hob.visual +└─ recipe "ScaldMilk" (no `under` — ceremony absent) + ├─ use_decl: use Milk as m qty qty(250,Mass) + └─ stmt → sync_block + ├─ actors = [Jamie_Hob] + ├─ proving = @scald_skin + └─ sync_item → step + ├─ actor=Jamie_Hob Verb=Heat + ├─ step_args: target = resource_id(m); ◀ acts on the Milk instance + │ arg_list=[ arg(target : qty(75,Temp)), + │ arg(slope : slope_tag GENTLE) ] + ├─ until = tropical_threshold( ~ temp >= qty(68,Temp) ) + ├─ max_duration = qty(6,Time) + └─ on_fail = ABORT +---- + +Why this *compiles* (and a variant *fails*): the requested setpoint +`target: 75C` and the `until` ceiling `68C` are both strictly below the milk's +`hard curdle: 82C`. The constitution §5 material-safety premise +`req ≺ danger(m)` holds (75C < 82C). Changing `target: 75C` to `target: 90C` +would parse identically but fail the §5 judgment with diagnostic `CURDLE` +(90C ≥ 82C). The grammar admits the program; the type system rejects the +dangerous one — exactly the v2.0 superpower. + +=== 6.3 A robot-arm plating step (Torque + reach + nested sync) + +Concrete syntax — exercises the `Arm` appliance's `Move`/`Whisk` capabilities, +the positional `target`, named torque/reach args, and O1 nesting: + +[source] +---- +material Plate { phase: COLD } +material Egg { phase: POACHED } +appliance Arm { + capability Move { quantity: Torque range: 0Nm .. 8Nm reach: 0cm .. 85cm } + capability Whisk { quantity: AngularRate range: 0rpm .. 1200rpm } +} +chef Baridi controls Arm style PRECISION; +echo @placed on Baridi.visual; + +recipe PlateEgg { + use Egg as e; + sync(Baridi) proving @placed { + Baridi.Move(e, torque: 6Nm, reach: 40cm) + until @placed == true + max_duration 20s + on_fail RECOVER; + } +} +---- + +Parse-tree sketch: + +---- +program +├─ decl: material_decl Plate { phase:COLD } +├─ decl: material_decl Egg { phase:POACHED } +├─ decl: appliance_decl Arm +│ ├─ capability_block Move +│ │ ├─ quantity = Torque +│ │ ├─ range = qty(0,Torque) .. qty(8,Torque) +│ │ └─ reach = qty(0,Length) .. qty(85,Length) +│ └─ capability_block Whisk +│ ├─ quantity = AngularRate +│ └─ range = qty(0,AngularRate) .. qty(1200,AngularRate) +├─ decl: chef_decl Baridi controls Arm style PRECISION +├─ decl: echo_decl @placed on Baridi.visual +└─ recipe "PlateEgg" + ├─ use_decl: use Egg as e + └─ stmt → sync_block + ├─ actors = [Baridi] + ├─ proving = @placed + └─ sync_item → step + ├─ actor=Baridi Verb=Move + ├─ step_args: target = resource_id(e); + │ arg_list=[ arg(torque : qty(6,Torque)), ◀ 6Nm ∈ [0,8]Nm ✓ + │ arg(reach : qty(40,Length)) ] ◀ 40cm ∈ [0,85]cm ✓ + ├─ until = echo_witness( @placed == boolean(true) ) + ├─ max_duration = qty(20,Time) + └─ on_fail = RECOVER +---- + +Why this *compiles* (and a variant *fails*): `torque: 6Nm` lies inside the +`Move` capability's `range: 0Nm .. 8Nm` and `reach: 40cm` lies inside +`reach: 0cm .. 85cm`. The constitution §5 device-envelope premise +`req ⊆ cap` holds. Raising `torque: 6Nm` to `torque: 9Nm` parses identically +but fails §5 with diagnostic `OVER_TORQUE` (9Nm ∉ [0,8]Nm); setting +`reach: 90cm` fails with `OVER_REACH` (90cm ∉ [0,85]cm). The grammar is the +same; the capability envelope in the types is what rejects the over-reach. + +[[ambiguity]] +== 7. Ambiguities and how they are resolved + +[cols="1,3,3", options="header"] +|=== +| Site | Potential ambiguity | Resolution + +| `stmt` dispatch +| Four `stmt` alternatives (`sync_block`, `bind_step`, `use_decl`, + `resource_decl`) share no common prefix *except* that `bind_step` and a + hypothetical parenthesised form both start with `(`. +| *LL(1) on the keyword.* `sync`→`sync_block`, `use`→`use_decl`, + `resource`→`resource_decl`, `(`→`bind_step`. Since `(` uniquely begins + `bind_step` (there is no parenthesised statement), one token suffices. + +| `bind_step` vs grouping +| The constitution's §3 sketch shows `(res, res+)`; `(` could in principle + open a grouped expression. +| There are *no* grouped expressions at statement level (the expression + sub-language §<> is conditions only, never statements). `(` at `stmt` + position is therefore unambiguously a `bind_step`. LL(2) confirms: the token + after the first `resource_id` is `,` or `)`, never an operator. + +| `chef_decl` arms +| `chef A style …` vs `chef A controls …` share the prefix `chef` `actor_id`. +| *LL(2):* the token after `actor_id` is `style` or `controls`. One extra + token of lookahead selects the arm. This is the only LL(2) decision in the + declaration grammar. + +| `step_args` target vs first named arg +| `Heat(target: 82C)` — is `target` a positional `target` or a named `arg`? +| *Maximal-munch favouring named args by syntax shape.* The parser reads the + first arg; if it is `identifier ":" value` it is an `arg` (named), so the + positional `target` slot is empty. A bare `resource_id`/`qty`/`type_name` + *not* followed by `:` is the positional `target`. The grammar makes + `target` optional precisely so this is decidable with one token of + lookahead after `(`: `IDENT ":"`→named arg; `IDENT ")"`/`IDENT ","`→ + positional target. Both worked parses 6.1 (`Egg,` positional) and the + `Heat(target:…)` named form resolve cleanly. + +| `limit_kind` leading vs trailing +| Should it be `curdle: 82C hard` (trailing) or `hard curdle: 82C` (leading)? +| *Leading* (`[ limit_kind ], identifier, ":", qty`). Leading keeps the + `limit` production LL(1): on `soft`/`hard` the kind is explicit; on any + other token the kind defaults via the checker's name table. A trailing + keyword would require lookahead past the `qty` to know whether a `limit` + has ended, breaking LL(k) for small k. + +| unit lexing (`2C/s`) +| `2C/s` could be `2C` then `/` then `s`. +| *Maximal munch in the lexer* (§2): the longest valid `unit` following a + `number` is consumed as part of one `qty` token. `2C/s` is one + `qty(2, Temperature/Time)`. + +| keyword vs identifier +| `Heat` is a `Verb`; could it be a `metric` or `identifier`? +| *Position-determined, then maximal-munch on reserved words.* Reserved words + (§2 `keyword`) are never identifiers. `Verb`/`type_name` are PascalCase by + the lexer class (`?[A-Z]…?`); `metric`/`identifier` for lowercase. So + `temp` (a `metric`) and `Heat` (a `Verb`) are lexically distinguishable, + removing any need for semantic feedback into the lexer. + +| `echo_witness` `@` +| v1.0 wrote `"@", metric, cmp, value`; v2.0 folds the `@` into `echo_id`. +| `echo_id = "@" identifier` is a single token-ish unit at the lexer; the + `echo_witness` production is `echo_id comparison value`, which reproduces + the v1.0 token sequence exactly (`@white_set == true`). No ambiguity, and + every v1.0 `@metric cmp value` still parses. +|=== + +*Operator precedence.* The only operators are the comparison set +(`>= <= > < == !=`) inside `condition`, the dyadic `<~>` at `bind_step`, the +range `..`, the unit operators `· * /`, and the phase/product arrows +`-> <~>`. None of them nest in a way that needs a precedence table: a +`condition` is exactly one comparison (no chaining), `<~>` is a fixed +statement form, `..` appears only between two `qty` in `range`/`reach`/`slope`, +and the unit/arrow operators are confined to their host productions. The +grammar is therefore unambiguous without a precedence climber — a deliberate +simplification that keeps the reference recursive-descent parser +(`src/compiler/parser.ml`, constitution §4/§8) straightforward. + +== 8. Consolidated grammar (single listing) + +[source,ebnf] +---- +(* ===== Top level ===== *) +program = { import_decl }, { decl }, recipe, { recipe } ; (* [R] *) +import_decl = "import", string_lit, [ "as", identifier ], ";" ; (* [N] *) +decl = material_decl | tool_decl | appliance_decl (* [N] *) + | resource_decl | chef_decl | echo_decl (* O2 *) + | ceremony_decl ; (* [A/P] *) + +(* ===== Declarations ===== *) +material_decl = "material", type_name, "{", phase_clause, { envelope_block }, "}" ; (* [N] *) +tool_decl = "tool", type_name, "{", "material", ":", type_name, + { envelope_block }, "}" ; (* [N] *) +appliance_decl= "appliance", type_name, "{", capability_block, + { capability_block }, "}" ; (* [N] *) +phase_clause = "phase", ":", phase_state, [ phase_arrows ] ; (* [N] *) +phase_arrows = "transitions", "{", phase_edge, { ",", phase_edge }, "}" ; +phase_edge = phase_state, "->", phase_state, [ "ruin" ] ; +phase_state = type_name ; +envelope_block= thermal_block | mechanical_block | chemical_block ; +thermal_block = "thermal", "{", { limit }, "}" ; +mechanical_block = "mechanical","{", { limit }, "}" ; +chemical_block= "chemical", "{", { chem_limit }, "}" ; +limit = [ limit_kind ], identifier, ":", qty ; +limit_kind = "soft" | "hard" ; +chem_limit = "pH", ":", number, "..", number + | "incompatible", ":", "{", type_name, { ",", type_name }, "}" ; +capability_block = "capability", Verb, "{", + "quantity", ":", dimension, + "range", ":", qty, "..", qty, + [ "slope", ":", qty, "..", qty ], + [ "reach", ":", qty, "..", qty ], + [ "jitter", ":", qty ], "}" ; +resource_decl = "resource", resource_id, "state", phase_state, + [ "of", type_name ], ";" ; (* O2 *) +chef_decl = "chef", actor_id, + ( "style", style_tag + | "controls", type_name, { ",", type_name }, [ "style", style_tag ] ), + ";" ; (* O2/[R] *) +echo_decl = "echo", echo_id, "on", actor_id, ".", modality, ";" ; (* O2 *) +ceremony_decl = "ceremony", ceremony_id, "{", + ceremony_setting, { ",", ceremony_setting }, "}" ; (* [A/P] *) +ceremony_setting = setting_key, ":", setting_value ; +setting_key = identifier ; setting_value = identifier | integer ; +style_tag = "PRECISION" | "AGGRESSIVE" | "GENTLE" | identifier ; +modality = "visual" | "acoustic" | "thermal" | identifier ; + +(* ===== Mission body ===== *) +recipe = recipe_kw, identifier, [ "under", ceremony_id ], "{", + { use_decl }, { stmt }, "}" ; (* [R] *) +recipe_kw = "recipe" | "orchestrate" ; +use_decl = "use", type_name, "as", resource_id, [ "qty", qty ], ";" ; (* [N] *) +stmt = sync_block | bind_step | use_decl | resource_decl ; (* [R] *) + +(* ===== Sync, bind, step (O1 closed) ===== *) +sync_block = "sync", "(", actors, ")", [ "proving", echo_id ], "{", + sync_item, { sync_item }, "}" ; (* [R] O1 *) +sync_item = step | sync_block | bind_step ; (* O1 *) +actors = actor, { ",", actor } ; +bind_step = "(", resource_id, ",", resource_id, { ",", resource_id }, ")", + "<~>", actor_id, [ "->", resource_id ], ";" ; (* [A/P]/[R] *) +step = actor, ".", Verb, "(", step_args, ")", + "until", condition, + "max_duration", qty, + "on_fail", error_handle, ";" ; (* [R] *) +step_args = [ target [ "," arg_list ] ] ; +target = resource_id | qty | type_name ; +arg_list = arg, { ",", arg } ; +arg = identifier, ":", arg_value ; +arg_value = qty | slope_tag | resource_id | type_name | style_tag ; +slope_tag = "GENTLE" | "STEADY" | "AGGRESSIVE" | qty ; +error_handle = "ABORT" | "RECOVER" | "WARM" ; + +(* ===== Conditions ===== *) +condition = tropical_threshold | echo_witness ; (* [A] *) +tropical_threshold = "~", metric, comparison, value ; (* [A] *) +echo_witness = echo_id, comparison, value ; (* [R] *) +comparison = ">=" | "<=" | ">" | "<" | "==" | "!=" ; +value = qty | boolean | type_name | number ; +boolean = "true" | "false" ; + +(* ===== Lexical (O3 closed) ===== *) +dimension = "Temperature" | "Time" | "Mass" | "Torque" | "AngularRate" + | "Length" | "Viscosity" | "Power" | "Dimensionless" + | dimension, ( "·" | "*" ), dimension + | dimension, "/", dimension ; +qty = number, unit ; +unit = base_unit | compound_unit ; +compound_unit = base_unit, ( "·" | "*" ), base_unit | base_unit, "/", base_unit ; +base_unit = "C" | "K" | "s" | "min" | "h" | "g" | "kg" | "mg" | "Nm" + | "rpm" | "mm" | "cm" | "m" | "Pa·s" | "Pas" | "W" | "kW" | "%" | "" ; +number = decimal ; decimal = ?-?[0-9]+(\.[0-9]+)? ; integer = ?-?[0-9]+? ; +identifier = ?[A-Za-z_][A-Za-z0-9_]*? ; +type_name = ?[A-Z][A-Za-z0-9_]*? ; Verb = type_name ; +metric = identifier ; actor = identifier ; actor_id = identifier ; +resource_id = identifier ; ceremony_id = identifier ; setting_key = identifier ; +echo_id = "@", identifier ; +string_lit = ?"([^"\\]|\\.)*"? ; +---- + +== 9. Relationship to the typed AST (constitution §4) + +Every production above has a direct image in `src/compiler/ast.ml` +(constitution §4): + +[cols="1,2", options="header"] +|=== +| Grammar production | AST node +| `qty` | `type qty = { mag : float; dim : dim }` +| `dimension` | `type dim = Temperature \| … \| Mul of dim*dim \| Div of dim*dim` +| `limit` / `limit_kind` | `type limit = { name; kind : Soft\|Hard; value : qty }` +| `thermal_block`/`mechanical_block`/`chemical_block` | `type envelope = { thermal; mechanical; chemical : limit list }` +| `material_decl` / `tool_decl` | `type material = { mname; phase; env; tool_of : string option }` +| `capability_block` | `type capability = { verb; quantity : dim; lo; hi : qty; slope; reach : (qty*qty) option }` +| `appliance_decl` | `type appliance = { aname; caps : capability list }` +| `step` | `type step = { actor; verb; target; args : (string*arg) list; cond; max_dur : qty; on_fail : fail }` +| `sync_block`/`bind_step`/`recipe`/`program` | the remaining AST sum nodes (constitution §4 "…") +|=== + +The `tool_of` field is how `tool_decl` reuses the `material` AST node +(constitution §2.3, "Modelled as a material with a `material:` tag"): a tool +parses through `tool_decl` and lowers to a `material` record with `tool_of = +Some "SILICONE"`. This is why `tool_decl`'s envelope grammar reuses +`envelope_block` verbatim — same physics, same checker premise +(`req ≺ danger(m)`), one diagnostic family (`MELT_TOOL`, `CHAR_TOOL`). + +== 10. Summary of open-item closures + +[cols="1,4", options="header"] +|=== +| Open item | Status in v2.0 +| *O1* — nested `sync_block`/`bind_step` inside a `sync` body | *CLOSED.* + `sync_item = step \| sync_block \| bind_step` (§4.3). Semantics: lexically + nested sub-sessions over an actor subset; per-level barrier, witness, and + linearity discipline; no new deadlock cycles because nesting is a tree. +| *O2* — `resource_decl`, `chef_decl`, `echo_decl` undefined | *CLOSED.* + Concrete productions in §3.4, matching `examples/poached-egg.ks` and + constitution §2.5. `chef_decl` admits both v1.0 `style` and v2.0 `controls`. +| *O3* — lexical placeholders undefined | *CLOSED.* Full lexer in §2: dimensioned + `qty` literals, `identifier`/`type_name`/`Verb`/`echo_id` classes, keyword + set, maximal-munch unit rule. +| NEW v2.0 declarations | `material_decl`, `tool_decl`, `appliance_decl` + (capability blocks), `use_decl`, `import_decl` (§3.1–§3.3, §3.6). +| `orchestrate` → `recipe` | `recipe_kw = "recipe" \| "orchestrate"`: + rename with deprecated synonym, so v1.0 files parse unchanged. +| extended `step` | positional `target` + named envelope `arg`s (§4.1), + refining the constitution §3 sketch. +|=== diff --git a/docs/spec/0003-type-system.adoc b/docs/spec/0003-type-system.adoc new file mode 100644 index 0000000..ff234e2 --- /dev/null +++ b/docs/spec/0003-type-system.adoc @@ -0,0 +1,906 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell += KitchenSpeak v2.0 — The Normative Type System +:toc: +:toclevels: 3 +:icons: font +:stem: latexmath + +[NOTE] +==== +This document is the *normative type system* for KitchenSpeak v2.0. It +elaborates the binding `docs/design/0001-v2-constitution.adoc`, in particular +the keystone physical-safety judgment (constitution §5), into a complete set +of inference rules. It adds detail; it does not contradict the constitution. + +Where the constitution states a property in prose (e.g. "a step is safe only +inside the intersection of the device and material envelopes"), this document +gives the rule, the side conditions, the well-formedness obligations, and the +exact diagnostic the reference checker (`src/compiler/check.ml`) emits when the +rule fails. + +The judgments here are what `proofs/agda/NoCurdle.agda` and its siblings +mechanise. The Agda files are the proof that *this* type system is sound; this +document is the type system they prove sound. +==== + +== 0. Notation and reading guide + +Inference rules are written in the usual natural-deduction style: premises +above the line, conclusion below. A double line denotes a definitional +equivalence (an algorithmic rule the checker computes rather than searches). + +[stem] +++++ +\frac{\text{premise}_1 \quad \cdots \quad \text{premise}_n}{\text{conclusion}}\ \textsc{(Rule-Name)} +++++ + +The metavariables are fixed throughout: + +[cols="1,4", options="header"] +|=== +| Symbol | Meaning +| stem:[\Gamma] | the *unrestricted* zone of the context (materials, appliances, chefs, echoes, ceremony). Reusable; weakening and contraction allowed. +| stem:[\Delta] | the *linear* zone of the context (resources). Substructural; carries QTT grades. +| stem:[\Gamma \mid \Delta] | the *split context*: an unrestricted zone paired with a linear zone. +| stem:[q] | a QTT grade, stem:[q \in \{0, 1, \omega\}]. +| stem:[d] | a dimension (Temperature, Time, Mass, Torque, AngularRate, Length, Viscosity, Power, Dimensionless, products and quotients). +| stem:[\kappa] | a quantity, stem:[\kappa = (m, d)] with rational magnitude stem:[m] in base units and dimension stem:[d]. +| stem:[\mathcal{E}] | an *envelope*: a dimension-indexed family of intervals (see §3). +| stem:[\mathcal{M}] | a material with safety envelope stem:[\mathcal{E}_\mathcal{M}] and danger set stem:[\mathsf{danger}(\mathcal{M})]. +| stem:[\mathcal{A}] | an appliance with capability set stem:[\mathsf{caps}(\mathcal{A})]. +| stem:[\sigma] | a chef style modifier (an envelope-shrinking map). +| stem:[\vdash] | "is well-typed at"; subscripted by judgment kind where helpful. +|=== + +The seven elevated types of the constitution map to typing rules as follows, +each developed in its own subsection of §6: + +[cols="1,1,3", options="header"] +|=== +| Type | Constructor | Rule family +| Primitive | dimensioned literal | `T-Lit`, `T-QMul`, `T-QAdd`, `T-Cmp` (§2) +| Tropical | `~ v slope s` | `T-Tropical` (§6.1) +| Linear | `use … as r` | `T-Use`, `T-Consume`, split/merge (§1, §6.2) +| Dyadic | `(x,y) <~> A` | `T-Bind` (§6.3) +| Choreographic| `sync(A…) { … }` | `T-Sync`, `T-Project` (§6.4) +| Echo | `proving @e` | `T-Echo`, `T-Proving` (§6.5) +| Ceremonial | `under C` | `T-Ceremony` (§6.6) +|=== + +== 1. Judgment forms and the split context + +=== 1.1 The split context + +A KitchenSpeak context is split into two zones with different structural +rules, exactly as in Quantitative Type Theory (QTT) and dual-zone linear +calculi (Barber's DILL): + +[stem] +++++ +\Gamma ::= \cdot \mid \Gamma, x : \tau \qquad\qquad +\Delta ::= \cdot \mid \Delta, r :_{q} \mathcal{M}\langle s \rangle +++++ + +* The *unrestricted zone* stem:[\Gamma] holds non-consumable facts: + declared materials, appliances (with their capability envelopes), chefs + (with their style modifiers), echoes (with their modality), and the + ambient ceremony. Entries are facts about the world; they may be used + zero, one, or many times. Both weakening (`U-Weak`) and contraction + (`U-Contr`) hold for stem:[\Gamma]. + +* The *linear zone* stem:[\Delta] holds *resources*: linear instances of + materials brought into the mission. An entry stem:[r :_q \mathcal{M}\langle s\rangle] + reads "resource `r` is an instance of material stem:[\mathcal{M}] in + phase typestate stem:[s], with usage grade stem:[q]". Neither weakening + nor contraction holds for stem:[\Delta] in general; usage is governed by + the grades. + +The grades stem:[q \in \{0,1,\omega\}] form the standard zero-one-many +semiring stem:[(0,1,\omega; +, \cdot)] with stem:[1+1=\omega], +stem:[\omega+\omega=\omega], stem:[1\cdot\omega=\omega], stem:[0\cdot q=0]: + +* stem:[q=0] — the resource is *erased*: mentioned in types/proofs but not + consumed at run time (e.g. a resource carried only for provenance). +* stem:[q=1] — the resource must be consumed *exactly once*. This is the + default for `use M as r;`. +* stem:[q=\omega] — unrestricted use. **Reserved**: in KitchenSpeak v2.0 no + surface construct introduces an stem:[\omega]-graded resource (you cannot + duplicate an egg). The grade exists so the metatheory is the standard QTT, + and so the unrestricted zone stem:[\Gamma] can be understood as the + stem:[\omega]-graded fragment. + +=== 1.2 The four judgment forms + +[stem] +++++ +\begin{array}{ll} +\Gamma \vdash d : \mathsf{Dim} + & \text{(dimension well-formedness)}\\[2pt] +\Gamma \mid \Delta \vdash e : \mathsf{Quantity}[d] + & \text{(quantity typing)}\\[2pt] +\Gamma \mid \Delta \vdash \mathit{step} : \mathsf{Safe} \dashv \Delta' + & \text{(physically-safe step; residual linear zone } \Delta')\\[2pt] +\Gamma \mid \Delta \vdash \mathit{recipe} : \mathsf{Total} + & \text{(whole mission is total and leak-free)} +\end{array} +++++ + +The step judgment is in *output-zone* (consumption) form: it threads the +linear zone, returning the residual stem:[\Delta'] after the step has +consumed what it consumes. This is how leak and double-use are detected +(§5). The recipe judgment requires the residual zone at mission end to be +*grade-saturated* — every grade-1 resource consumed (§6.7). + +=== 1.3 Structural rules + +The two zones differ exactly in their structural rules. + +[stem] +++++ +\frac{\Gamma \mid \Delta \vdash e : \mathsf{Quantity}[d]} + {\Gamma, x:\tau \mid \Delta \vdash e : \mathsf{Quantity}[d]}\ \textsc{(U-Weak)} +\qquad +\frac{\Gamma, x:\tau, y:\tau \mid \Delta \vdash J} + {\Gamma, x:\tau \mid \Delta \vdash J[y{:=}x]}\ \textsc{(U-Contr)} +++++ + +[stem] +++++ +\frac{\Gamma \mid \Delta \vdash J} + {\Gamma \mid \Delta, r:_{0}\mathcal{M}\langle s\rangle \vdash J}\ \textsc{(L-Weak}_0\textsc{)} +\qquad +\textit{(no contraction on } \Delta;\ \text{no weakening for } q\neq 0) +++++ + +`L-Weak`~0~ says only a *grade-0* (erased) resource may be introduced +without being used; this is what makes "an unused grade-1 resource" a leak +rather than a silently-discarded value. There is no contraction rule for +stem:[\Delta]: a resource cannot be split into two usable copies. + +=== 1.4 Context split and merge + +When a construct has two sub-derivations that each consume resources (the two +factors of a dyadic bind; two actors in a sync), the linear zone is *split* +between them by the grade-addition relation stem:[\Delta = \Delta_1 + \Delta_2]: + +[stem] +++++ +\frac{}{\cdot = \cdot + \cdot} +\qquad +\frac{\Delta = \Delta_1 + \Delta_2 \quad q = q_1 + q_2} + {\Delta, r:_{q}\mathcal{M}\langle s\rangle \;=\; (\Delta_1, r:_{q_1}\mathcal{M}\langle s\rangle) + (\Delta_2, r:_{q_2}\mathcal{M}\langle s\rangle)} +++++ + +Splitting a grade-1 resource (stem:[1 = q_1 + q_2]) forces stem:[\{q_1,q_2\}=\{0,1\}]: +exactly one side may actually consume it; the other holds it erased. This is +the rule that makes `DOUBLE_USE` impossible (§5): you cannot derive +stem:[1 = 1 + 1] because stem:[1+1=\omega \neq 1]. + +The unrestricted zone is *shared*, not split: both sub-derivations see all of +stem:[\Gamma]. Merging residuals after sequencing is ordinary append, since a +consumed grade-1 resource has been demoted to grade 0 in the residual. + +== 2. Dimensions and quantities (Primitive) + +=== 2.1 Dimension well-formedness + +The base dimensions and their closure under product and quotient form a free +abelian group on the SI base set used by the kitchen. A dimension is a vector +of integer exponents over the generators +stem:[\{\Theta\,(\text{Temp}), \mathrm{T}\,(\text{Time}), \mathrm{M}\,(\text{Mass}), \mathrm{N}{\cdot}\mathrm{m}\,(\text{Torque}), \mathrm{L}\,(\text{Length})\}], +with the derived dimensions defined by: + +[stem] +++++ +\text{AngularRate} = \mathrm{T}^{-1},\quad +\text{Power} = \mathrm{N{\cdot}m}\cdot\mathrm{T}^{-1},\quad +\text{Viscosity} = \mathrm{M}\cdot\mathrm{L}^{-1}\cdot\mathrm{T}^{-1},\quad +\text{Dimensionless} = \mathbf{0}. +++++ + +[stem] +++++ +\frac{}{\Gamma \vdash \mathsf{Dimensionless} : \mathsf{Dim}}\ \textsc{(D-Unit)} +\qquad +\frac{d_1 \text{ base or derived} }{\Gamma \vdash d_1 : \mathsf{Dim}}\ \textsc{(D-Base)} +++++ + +[stem] +++++ +\frac{\Gamma \vdash d_1 : \mathsf{Dim} \quad \Gamma \vdash d_2 : \mathsf{Dim}} + {\Gamma \vdash d_1 \cdot d_2 : \mathsf{Dim}}\ \textsc{(D-Mul)} +\qquad +\frac{\Gamma \vdash d_1 : \mathsf{Dim} \quad \Gamma \vdash d_2 : \mathsf{Dim}} + {\Gamma \vdash d_1 / d_2 : \mathsf{Dim}}\ \textsc{(D-Div)} +++++ + +Dimensions are compared *up to the abelian-group equations* +(stem:[d_1\cdot d_2 = d_2\cdot d_1], stem:[d/d=\mathbf 0], etc.): the checker +normalises both sides to an exponent vector before comparison. Thus +stem:[\mathrm{N{\cdot}m}\cdot\mathrm{T}^{-1}] and stem:[\text{Power}] are +*the same dimension*, not merely convertible. + +=== 2.2 Dimensioned literals + +A literal carries a unit suffix that fixes both its magnitude (after +normalisation to the base unit) and its dimension. Let stem:[\mathsf{base}(u)] +be the base-unit conversion of unit suffix stem:[u] and stem:[\mathsf{dim}(u)] +its dimension. + +[stem] +++++ +\frac{m \in \mathbb{Q} \quad u \text{ a unit suffix} \quad \Gamma \vdash \mathsf{dim}(u) : \mathsf{Dim}} + {\Gamma \mid \cdot \vdash (m\,u) : \mathsf{Quantity}[\mathsf{dim}(u)]}\ \textsc{(T-Lit)} +++++ + +Worked instances (the canonical ones from the constitution): + +[cols="2,1,3", options="header"] +|=== +| Literal | Base form | Conclusion +| `82C` | stem:[(82,\Theta)] | stem:[\vdash \mathtt{82C} : \mathsf{Quantity}[\text{Temperature}]] +| `5min` | stem:[(300,\mathrm{T})] | stem:[\vdash \mathtt{5min} : \mathsf{Quantity}[\text{Time}]] (5 min = 300 s) +| `300g` | stem:[(0.3,\mathrm{M})] | stem:[\vdash \mathtt{300g} : \mathsf{Quantity}[\text{Mass}]] (300 g = 0.3 kg) +| `8Nm` | stem:[(8,\mathrm{N{\cdot}m})]| stem:[\vdash \mathtt{8Nm} : \mathsf{Quantity}[\text{Torque}]] +| `80rpm` | stem:[(\tfrac{80}{60},\mathrm{T}^{-1})] | stem:[\vdash \mathtt{80rpm} : \mathsf{Quantity}[\text{AngularRate}]] +| `4cm` | stem:[(0.04,\mathrm{L})] | stem:[\vdash \mathtt{4cm} : \mathsf{Quantity}[\text{Length}]] +| `0.8Pa·s` | stem:[(0.8,\mathrm{M L^{-1} T^{-1}})] | stem:[\vdash \mathtt{0.8Pa·s} : \mathsf{Quantity}[\text{Viscosity}]] +| `2C/s` | stem:[(2,\Theta\,\mathrm{T}^{-1})] | stem:[\vdash \mathtt{2C/s} : \mathsf{Quantity}[\text{Temperature}/\text{Time}]] +|=== + +So `82C : Quantity[Temperature]` because the suffix `C` has +stem:[\mathsf{dim}(\mathtt{C}) = \Theta = \text{Temperature}]; the magnitude +82 is irrelevant to the type, only to the later interval checks of §3–§5. +A literal carries the empty linear zone (literals consume no resources). + +=== 2.3 Dimension-preserving arithmetic + +Multiplication adds dimension exponents; division subtracts; addition and +comparison require equal dimensions. + +[stem] +++++ +\frac{\Gamma\mid\Delta_1 \vdash e_1 : \mathsf{Quantity}[d_1] \quad \Gamma\mid\Delta_2 \vdash e_2 : \mathsf{Quantity}[d_2]} + {\Gamma\mid\Delta_1+\Delta_2 \vdash e_1 \times e_2 : \mathsf{Quantity}[d_1\cdot d_2]}\ \textsc{(T-QMul)} +++++ + +[stem] +++++ +\frac{\Gamma\mid\Delta_1 \vdash e_1 : \mathsf{Quantity}[d_1] \quad \Gamma\mid\Delta_2 \vdash e_2 : \mathsf{Quantity}[d_2]} + {\Gamma\mid\Delta_1+\Delta_2 \vdash e_1 / e_2 : \mathsf{Quantity}[d_1 / d_2]}\ \textsc{(T-QDiv)} +++++ + +[stem] +++++ +\frac{\Gamma\mid\Delta_1 \vdash e_1 : \mathsf{Quantity}[d] \quad \Gamma\mid\Delta_2 \vdash e_2 : \mathsf{Quantity}[d]} + {\Gamma\mid\Delta_1+\Delta_2 \vdash e_1 + e_2 : \mathsf{Quantity}[d]}\ \textsc{(T-QAdd)} +++++ + +[stem] +++++ +\frac{\Gamma\mid\Delta_1 \vdash e_1 : \mathsf{Quantity}[d] \quad \Gamma\mid\Delta_2 \vdash e_2 : \mathsf{Quantity}[d] \quad {\bowtie}\in\{<,\le,=,\ge,>\}} + {\Gamma\mid\Delta_1+\Delta_2 \vdash (e_1 \bowtie e_2) : \mathsf{Bool}}\ \textsc{(T-Cmp)} +++++ + +The premise stem:[d_1 = d_2] in `T-QAdd`/`T-Cmp` is where the +constitution's "comparing `82C` with `5min` is a type error" becomes a +*missing derivation*: there is no rule deriving stem:[\mathtt{82C} \ge \mathtt{5min}] +because stem:[\Theta \neq \mathrm{T}]. The checker emits *`DIM_MISMATCH`*: + +[example] +==== +`82C >= 5min` ⇒ `DIM_MISMATCH: cannot compare Temperature with Time` +(no instance of `T-Cmp`; the two operands fail the stem:[d_1=d_2] premise). +==== + +*Dimension-preservation lemma.* If +stem:[\Gamma\mid\Delta \vdash e : \mathsf{Quantity}[d]] then every +subexpression is well-dimensioned and stem:[d] is uniquely determined by the +syntax of stem:[e] (the rules are syntax-directed on the operator). Proof: by +induction; each operator has exactly one applicable rule. + +== 3. Envelopes, materials and appliances + +=== 3.1 Envelopes and the two subset orders + +An *envelope* stem:[\mathcal{E}] is a finite map from dimensions to closed +rational intervals (plus, optionally, derived-quantity intervals for *slope* +and *reach*): + +[stem] +++++ +\mathcal{E} : d \rightharpoonup [\,\ell_d,\ h_d\,], \qquad \ell_d, h_d \in \mathbb{Q}\cup\{-\infty,+\infty\}. +++++ + +A *requested envelope* stem:[\mathsf{req}] is the (finite) envelope assembled +from a step's arguments: each dimensioned argument contributes a point or +interval at its dimension (a setpoint `target: 82C` contributes the point +interval stem:[[82,82]] at Temperature; `slope: 5C/s` contributes +stem:[[\,\cdot,5]] at Temperature/Time; `reach: 4cm` a point at Length). + +We need two distinct order relations on envelopes — *containment* for device +capability, and *strict-domination* for material safety — and they are not the +same relation: + +[stem] +++++ +\boxed{\ \mathsf{req} \subseteq \mathcal{E}\ } \iff +\forall d \in \mathrm{dom}(\mathsf{req}).\ d\in\mathrm{dom}(\mathcal E) \ \wedge\ +\ell^{\mathcal E}_d \le \ell^{\mathsf{req}}_d \ \wedge\ h^{\mathsf{req}}_d \le h^{\mathcal E}_d +++++ + +[stem] +++++ +\boxed{\ \mathsf{req} \prec D\ } \iff +\forall (d, L) \in D.\ h^{\mathsf{req}}_d \;<\; L +\qquad\text{(strict, against each hard limit } L \text{ at dimension } d) +++++ + +* *Envelope-subset* stem:[\mathsf{req}\subseteq\mathcal{E}] ("the request fits + inside the device") is *non-strict, closed-interval containment*: a device + rated to exactly `250C` may be asked for exactly `250C`. Every requested + dimension must be *present* in stem:[\mathcal E] (a device with no Torque + capability cannot be asked for torque) — that absence is `NO_CAPABILITY`, + §4. +* *Strict-domination* stem:[\mathsf{req}\prec D] ("the request stays clear of + every danger limit") is *strict, open* below each hard limit in the danger + set stem:[D]: reaching a hard limit is already ruin, so `<`, never + `\le`. A request that omits a dimension present in stem:[D] vacuously + dominates at that dimension (it does not push that quantity at all). + +This asymmetry is deliberate and load-bearing: *you may use a device to its +rated edge, but you may never push a material to its ruin edge.* + +=== 3.2 The soft/hard (danger) distinction + +Each named limit in a material's envelope is classified stem:[\mathsf{Soft}] +or stem:[\mathsf{Hard}]. The *danger set* of a material is the projection onto +its hard limits: + +[stem] +++++ +\mathsf{danger}(\mathcal{M}) \;=\; \{\,(d, v) \mid (\mathit{name}, \mathsf{Hard}, (v,d)) \in \mathcal{E}_\mathcal{M}\,\}. +++++ + +[cols="1,2,3", options="header"] +|=== +| Class | Semantics | Checker action +| `soft` | quality threshold (`scald: 70C`) | *warning* if stem:[\mathsf{req}] crosses it but stays below the next hard limit; never blocks compilation. +| `hard` | ruin/hazard threshold (`curdle: 82C`) | *error* (the named diagnostic) if stem:[\mathsf{req} \not\prec] this limit. Always blocks. +|=== + +For Milk `{ scald: 70C (soft), curdle: 82C (hard), freeze: 0C (hard) }`: +stem:[\mathsf{danger}(\text{Milk}) = \{(\Theta,82),(\Theta,0)\}], read as +"the safe open band at Temperature is stem:[(0,82)]". A request of `71C` +warns (past `scald`); a request of `82C` is rejected (`CURDLE`); a request of +`60C` is clean. + +=== 3.3 Materials and tools enter the unrestricted zone + +A `material`/`tool` declaration is a *fact about a substance*, so it enters +stem:[\Gamma] (it is not consumed). It records the phase typestate and the +envelope. A tool is a material with a `material:` tag and, by convention, only +thermal/mechanical limits. + +[stem] +++++ +\frac{\mathcal{M} = (\mathit{name}, \mathit{phase}, \mathcal{E}_\mathcal{M}) \quad \text{envelope well-formed (§7)}} + {\Gamma \vdash \mathtt{material}\ \mathit{name}\ \{\dots\} \;\rightsquigarrow\; \Gamma, \mathit{name} : \mathsf{Material}[\mathcal{E}_\mathcal{M}]}\ \textsc{(T-MatDecl)} +++++ + +A `use M as r;` *instantiates* a material as a linear resource, moving an +entry into the linear zone at grade 1 in the material's declared phase: + +[stem] +++++ +\frac{\Gamma \vdash M : \mathsf{Material}[\mathcal{E}] \quad \mathsf{phase}_0(M)=s} + {\Gamma \mid \Delta \;\vdash\; \mathtt{use}\ M\ \mathtt{as}\ r;\ \dashv\ \Delta, r:_{1}\,M\langle s\rangle}\ \textsc{(T-Use)} +++++ + +=== 3.4 Appliances and chefs enter the unrestricted zone + +An appliance declaration enters stem:[\Gamma] carrying its capability set. +Each capability is a verb plus a device envelope: + +[stem] +++++ +\mathsf{caps}(\mathcal{A}) = \{\, V \mapsto \mathcal{E}^{\mathcal A}_V \,\}, \qquad +\mathcal{E}^{\mathcal A}_V \text{ from } \mathtt{range}/\mathtt{slope}/\mathtt{reach}. +++++ + +[stem] +++++ +\frac{\text{each capability envelope well-formed (§7)}} + {\Gamma \vdash \mathtt{appliance}\ \mathit{name}\ \{\dots\} \;\rightsquigarrow\; \Gamma, \mathit{name} : \mathsf{Appliance}[\mathsf{caps}]}\ \textsc{(T-ApplDecl)} +++++ + +A `chef C controls A style σ;` binds an actor name `C` to an appliance and a +*style modifier* stem:[\sigma]. The style is an envelope endomorphism that may +only *shrink*: + +[stem] +++++ +\frac{\Gamma \vdash A : \mathsf{Appliance}[\mathsf{caps}] \quad \forall V.\ \sigma(\mathcal{E}^{A}_V) \subseteq \mathcal{E}^{A}_V} + {\Gamma \vdash \mathtt{chef}\ C\ \mathtt{controls}\ A\ \mathtt{style}\ \sigma; \;\rightsquigarrow\; \Gamma, C : \mathsf{Actor}[A, \sigma]}\ \textsc{(T-Chef)} +++++ + +The side condition stem:[\sigma(\mathcal E)\subseteq\mathcal E] +(*style-monotonicity*) is what makes the constitution's "style can only shrink +envelopes, never widen them" a *checked* property: a `chef` declaration whose +style would widen any range is rejected (`STYLE_WIDENS`, §5, well-formedness). +For `Delia style PRECISION`, stem:[\sigma_{\text{PRECISION}}] tightens the +slope interval (low jitter) and leaves the range endpoints in place, so +stem:[\sigma(\mathcal E)\subseteq\mathcal E] holds. + +== 4. The physical-safety judgment (keystone, constitution §5) + +This is the heart of the language. We give the constitution's stem:[\mathsf{Safe}] +rule in full, with all three premises made precise and the linear-zone +threading attached. + +Let a step be stem:[\mathit{st} = C.V(\mathit{target}, \overline{\mathit{arg}})], +let stem:[A,\sigma] be the actor's appliance and style +(stem:[C:\mathsf{Actor}[A,\sigma]\in\Gamma]), let +stem:[\mathsf{req} = \mathsf{env}(\overline{\mathit{arg}})] be the requested +envelope assembled from the arguments, and let +stem:[\mathsf{touched}(\mathit{target})] be the set of materials and tools the +step exposes (the target resource and any tool/acidulant arguments). + +[stem] +++++ +\frac{ +\begin{array}{c} +\underbrace{V \in \mathrm{dom}(\mathsf{caps}(A))}_{\textbf{(1) capability}} \qquad +\underbrace{\mathsf{req} \;\subseteq\; \sigma\!\left(\mathcal{E}^{A}_{V}\right)}_{\textbf{(2) device envelope, after style}} \\[10pt] +\underbrace{\forall \mathcal{M} \in \mathsf{touched}(\mathit{target}).\quad \mathsf{req} \;\prec\; \mathsf{danger}(\mathcal{M})}_{\textbf{(3) material safety: strictly below every hard limit}} +\end{array} +}{ +\Gamma \mid \Delta,\, target:_{1} \mathcal{M}_t\langle s\rangle \;\vdash\; C.V(target, \overline{\mathit{arg}}) : \mathsf{Safe} \;\dashv\; \Delta,\, target:_{0} \mathcal{M}_t\langle s'\rangle +}\ \textsc{(T-Safe)} +++++ + +Reading the three premises operationally — exactly the three things +`check.ml` proves for every step — and the diagnostic each emits on failure: + +[cols="1,3,2", options="header"] +|=== +| Premise | What it checks | Diagnostic on failure +| **(1) Capability** | the controlling appliance actually has verb stem:[V]. | *`NO_CAPABILITY`* +| **(2) Device envelope** | every requested quantity (target temp, slope, torque, rpm, reach) lies in the appliance's range *after* the style modifier — non-strict subset stem:[\subseteq]. | *`OVER_TORQUE`* / *`OVER_REACH`* (and `OUT_OF_RANGE` for other dims) +| **(3) Material safety** | for every touched material/tool, stem:[\mathsf{req}] is *strictly* below every hard limit at the verb's dimension — strict domination stem:[\prec]. | *`CURDLE`* / *`SCORCH`* / *`MELT_TOOL`* (by which material/limit is violated) +|=== + +The linear-zone effect of the rule: the step *consumes* its target (grade +stem:[1\to 0]) and may advance its phase typestate +(stem:[s \to s']). The residual stem:[\Delta] carries the now-erased +resource so that any *second* mention of `target` finds it at grade 0 and +fails to re-consume (`DOUBLE_USE`, §5). + +=== 4.1 Why premise (2) is stem:[\subseteq] but premise (3) is stem:[\prec] + +A device may be operated to its certified edge: an oven rated `300C` may bake +at `300C`. Hence containment is *closed* (stem:[\le]). A material at its ruin +limit is *already ruined*: milk *at* `82C` is curdling. Hence domination is +*strict* (stem:[<]). The whole safety story is the conjunction: the request +must fit the device (closed) **and** clear every danger limit (open). A step +is safe only inside the *intersection* stem:[\sigma(\mathcal E^A_V) \cap \bigcap_{\mathcal M} \mathsf{safe}(\mathcal M)], +where stem:[\mathsf{safe}(\mathcal M)] is the open complement of the danger +set — constitution §2.4's "a step is safe only inside the intersection of the +two envelopes," now formal. + +=== 4.2 Worked instances of `T-Safe` + +[example] +.Accepted — `ScaldMilk` (the NoCurdle flagship) +==== +`Delia_Hob.Heat(target: 80C, slope: GENTLE)` touching `m : Milk`. + +. stem:[\mathtt{Heat}\in\mathsf{caps}(\text{Hob})] ✓ +. stem:[\mathsf{req}=\{\Theta\mapsto[80,80],\ \Theta\mathrm{T}^{-1}\mapsto[\cdot,1]\}] + (GENTLE stem:[\Rightarrow] slope stem:[\le 1\mathrm{C/s}]); + stem:[\sigma_{\text{PRECISION}}(\mathcal E^{\text{Hob}}_{\text{Heat}}) = \{\Theta\mapsto[0,250],\ \Theta\mathrm{T}^{-1}\mapsto[0,3]\}]. + stem:[\mathsf{req}\subseteq] this ✓ (stem:[80\le250], slope stem:[1\le3]). +. stem:[\mathsf{danger}(\text{Milk})=\{(\Theta,82),(\Theta,0)\}]; + stem:[h^{\mathsf{req}}_\Theta = 80 < 82] ✓ and stem:[80 > 0] ✓ (freeze). + Note `80C` *does* cross the soft `scald: 70C` ⇒ **warning** (`SCALD_WARN`), + compilation proceeds. + +All three premises hold ⇒ step typed stem:[\mathsf{Safe}]. This is exactly +`NoCurdle.agda`'s `tropical-safe` specialised to one minute. +==== + +[example] +.Rejected — `CURDLE` +==== +`Delia_Hob.Heat(target: 82C, ...)` touching `m : Milk`. +Premise (3) fails: stem:[h^{\mathsf{req}}_\Theta = 82 \not< 82]. No instance +of `T-Safe`. Checker emits `CURDLE: Milk reaches hard thermal limit +curdle=82C at requested 82C`. +==== + +[example] +.Rejected — `MELT_TOOL` +==== +`Arm.Move(target: Pan, ...)` where the step exposes `Spatula : SILICONE` +(`melt: 260C (hard)`) to an oven set `target: 280C`. +Premise (3) fails at the *tool*: stem:[280 \not< 260]. Emits +`MELT_TOOL: SILICONE spatula reaches melt=260C at requested 280C`. +==== + +[example] +.Rejected — `OVER_TORQUE` / `OVER_REACH` / `NO_CAPABILITY` +==== +`Arm.Move(target: Jar, torque: 9Nm)` — premise (2) fails: +stem:[9 \not\le 8 = h^{\mathcal E}_{\mathrm{N\cdot m}}] ⇒ `OVER_TORQUE`. + +`Arm.Move(target: Shelf, reach: 90cm)` — stem:[0.9 \not\le 0.85] ⇒ +`OVER_REACH`. + +`Hob.Whisk(...)` — premise (1) fails, stem:[\mathtt{Whisk}\notin\mathsf{caps}(\text{Hob})] +⇒ `NO_CAPABILITY`. +==== + +== 5. Structural obligations and the residual zone + +Beyond `T-Safe`, every recipe must discharge the structural obligations of +constitution §5. These are conditions on how the linear zone is threaded. + +=== 5.1 Linearity: leak and double-use + +[stem] +++++ +\frac{\Gamma \mid \Delta,\, r:_{1}\mathcal{M}\langle s\rangle \vdash \mathit{rest} : \mathsf{Total} \dashv \Delta'} + {r \notin \mathrm{consumed}(\Delta')\ \Rightarrow\ \textbf{LEAK}}\ \textsc{(Chk-Leak)} +++++ + +[stem] +++++ +\frac{\Gamma \mid \Delta,\, r:_{0}\mathcal{M}\langle s\rangle \vdash C.V(r,\dots) : \mathsf{Safe} \dashv \cdots} + {\textbf{DOUBLE\_USE}\ (\text{cannot consume a grade-0 resource})}\ \textsc{(Chk-Double)} +++++ + +* *`LEAK`* — a resource still at grade 1 when the mission body ends. No rule + consumed it; `Chk-Leak` fires. ("an unused resource at recipe end is a leak + error" — constitution §5.) +* *`DOUBLE_USE`* — a step references a resource already at grade 0. `T-Safe`'s + input demands grade 1; the resource is at grade 0; no derivation exists. + Equivalently, the split rule (§1.4) cannot derive stem:[1=1+1]. ("a + twice-used resource is a double-use error" — constitution §5.) + +=== 5.2 Termination, totality, witness — the three mandatory constructs + +These are the *typing rules* for `max_duration`, `on_fail`, and `proving` +(constitution §3, §5; COMMENTARY "three production-mandatory constructs"). + +*`max_duration` — termination witness.* A step is well-formed only if it +carries a `max_duration` of dimension Time; that quantity is the well-founded +*fuel* measure on the implicit `until`-loop. + +[stem] +++++ +\frac{\Gamma\mid\Delta \vdash \mathit{body} : \mathsf{Safe} \dashv \Delta' \quad \Gamma\mid\cdot \vdash \mu : \mathsf{Quantity}[\text{Time}] \quad \mu > 0} + {\Gamma\mid\Delta \vdash \mathit{body}\ \mathtt{max\_duration}\ \mu : \mathsf{Terminating} \dashv \Delta'}\ \textsc{(T-MaxDur)} +++++ + +A step with no `max_duration` has no derivation ⇒ *`MISSING_MAXDUR`*. The +measure stem:[\mu] decreases by one tick per loop iteration; reaching 0 takes +the `on_fail` branch. This is the `fuel` parameter of `heat`/`poach`/`knead` +in the Agda proofs. + +*`on_fail` — total failure handler.* A step is total only if its failure +branch is inhabited by a named mode, making the step a total function into +stem:[\{\mathsf{success}\} \uplus \{\mathsf{aborted}, \mathsf{recovered}, \mathsf{warmed}\}]: + +[stem] +++++ +\frac{\Gamma\mid\Delta \vdash \mathit{body} : \mathsf{Terminating} \dashv \Delta' \quad f \in \{\mathtt{ABORT}, \mathtt{RECOVER}, \mathtt{WARM}\}} + {\Gamma\mid\Delta \vdash \mathit{body}\ \mathtt{on\_fail}\ f : \mathsf{Total} \dashv \Delta'}\ \textsc{(T-OnFail)} +++++ + +A step with no `on_fail` has no derivation ⇒ *`MISSING_ONFAIL`*. (Note: in +the failure image, a grade-1 target is still consumed — `RECOVER`/`WARM` do +not return the resource; an aborted egg is permanently lost, modelled by the +grade staying 0 in stem:[\Delta'].) + +*`proving @e` — named existential witness.* A `sync(…) proving @e` block is +total iff `@e` is a declared echo whose modality the controlling appliance +offers, supplying the existential sensor witness that releases the barrier. + +[stem] +++++ +\frac{ +\Gamma \vdash @e : \mathsf{Echo}[\mu_e] \quad +C : \mathsf{Actor}[A,\sigma]\in\Gamma \quad +\mu_e \in \mathsf{modalities}(A) \quad +\Gamma\mid\Delta \vdash \overline{\mathit{step}} : \mathsf{Total} \dashv \Delta' +}{ +\Gamma\mid\Delta \vdash \mathtt{sync}(C)\ \mathtt{proving}\ @e\ \{\overline{\mathit{step}}\} : \mathsf{Total}\ \&\ \exists t.\,@e(t) \dashv \Delta' +}\ \textsc{(T-Proving)} +++++ + +If `@e` is undeclared, or its modality stem:[\mu_e] is not offered by the +appliance, the block does not type ⇒ *`UNWITNESSED_SYNC`*. The existential +stem:[\exists t.\,@e(t)] is the `ShimmerWitness`/`WhiteWitness`/`Witness` +of the Agda proofs. + +== 6. The seven elevated types, as rules + +=== 6.1 Tropical — bounded-derivative refinement over a trajectory + +A tropical setpoint `~ v slope s` is not an equality: it is "approach value +stem:[v] with rate of change bounded by stem:[s]." Its type is a *refinement +over the time-indexed quantity stream* produced by the controller, carrying +both a value bound and a derivative bound, checked against the relevant +envelopes via `T-Safe`'s premises (2) and (3). + +[stem] +++++ +\frac{ +\Gamma\mid\cdot \vdash v : \mathsf{Quantity}[d] \quad +\Gamma\mid\cdot \vdash s : \mathsf{Quantity}[d/\text{Time}] \quad +[\,v - s\!\cdot\!\tau,\ v\,] \subseteq \sigma(\mathcal{E}^A_V)\!\restriction_d \quad +[\,\cdot,\ v\,] \prec \mathsf{danger}|_d +}{ +\Gamma\mid\Delta \vdash {\sim}\,v\ \mathtt{slope}\ s : \mathsf{Tropical}[d] +}\ \textsc{(T-Tropical)} +++++ + +The third premise checks the *whole reachable band* of the trajectory — from +the approach floor stem:[v - s\cdot\tau] up to the setpoint stem:[v] (over the +`max_duration` stem:[\tau]) — lies inside the device range; the fourth checks +the setpoint strictly dominates every hard limit at stem:[d]. With a bounded +slope, monotone approach to stem:[v] means *checking the endpoint stem:[v] +suffices for the upper hard limit* — this is precisely the integrator-bound +reasoning that `NoCurdle.agda`/`PoachedEgg.agda` discharge as `tropical-safe` +(stem:[\texttt{temp-at } t \le \text{target-cap} < \text{boil-envelope}]). A +trajectory whose setpoint or band crosses a hard limit fails ⇒ `SCORCH` (or +`CURDLE`); a slope outside the device slope range fails premise (2) ⇒ +`OUT_OF_RANGE` on the slope dimension. *Named-symbol slopes* (`GENTLE`, +`AGGRESSIVE`) desugar to concrete stem:[d/\text{Time}] quantities supplied by +the stdlib before this rule applies. + +=== 6.2 Linear — use-once with typestate (leak/double-use) + +The linear type is governed by the grades of §1 and the consumption threading +of `T-Safe`. `T-Use` (§3.3) introduces a grade-1 resource; `T-Safe` consumes +it (grade stem:[1\to0]) and advances its typestate stem:[s\to s']. The two +failure modes are `Chk-Leak` and `Chk-Double` (§5.1). The *ruin* typestates +(`CURDLED`, `SCORCHED`, `BURNT`) are absorbing: there is no rule with a +ruin-state resource in a *consumable* (non-erased) position, so once a +material's typestate would advance into ruin, `T-Safe`'s premise (3) has +already failed and the program never reaches that state — "make illegal +physical states unrepresentable." + +[stem] +++++ +\frac{C.V \text{ would drive } \mathcal M_t : s \to s_{\mathsf{ruin}} \quad s_{\mathsf{ruin}} \in \{\mathtt{CURDLED},\mathtt{SCORCHED},\mathtt{BURNT}\}} + {\text{premise (3) of \textsc{T-Safe} fails}\ \Rightarrow\ \text{ruin-named diagnostic; no transition into } s_{\mathsf{ruin}}}\ \textsc{(L-NoRuin)} +++++ + +=== 6.3 Dyadic — tensor bind with provenance + +The bind `(x, y) <~> A` is the tensor stem:[\mathcal M_x \otimes \mathcal M_y], +consumed by the binding actor stem:[A] to produce a new linear resource whose +typestate carries the *provenance* (union of relevant metadata) of both +factors. Both inputs are consumed; neither survives. + +[stem] +++++ +\frac{ +\Gamma \vdash A : \mathsf{Appliance}[\mathsf{caps}] \quad +\Delta = \Delta_1 + \Delta_2 \quad +\Delta_1 \ni x:_{1}\mathcal M_x\langle s_x\rangle \quad +\Delta_2 \ni y:_{1}\mathcal M_y\langle s_y\rangle +}{ +\Gamma \mid \Delta,\, x:_1\mathcal M_x, y:_1\mathcal M_y \;\vdash\; (x,y) \mathrel{\mathtt{<\!\sim\!>}} A;\ \dashv\ \Delta,\, x:_0, y:_0,\ z:_{1}(\mathcal M_x\otimes\mathcal M_y)\langle s_x \sqcup s_y\rangle +}\ \textsc{(T-Bind)} +++++ + +The split stem:[\Delta=\Delta_1+\Delta_2] guarantees the two factors are +*distinct* grade-1 resources (you cannot bind an egg to itself: that would +need stem:[1=1+1]). The result stem:[z] carries the joined provenance +stem:[s_x\sqcup s_y] — this is `bind-dough : Flour → Water → Dough KNEADABLE` +in `Dough.agda`, where Flour and Water are consumed and Dough emerges with the +joined typestate. The envelope of stem:[\mathcal M_x\otimes\mathcal M_y] is +the *componentwise intersection* of the two safety envelopes: a dough is no +more heat-tolerant than its least-tolerant constituent. + +=== 6.4 Choreographic — session typing, projection, deadlock-freedom + +A `sync(C₁,…,Cₙ){ … }` block is a multiparty session barrier. The global +choreography stem:[G] is the block body; each actor stem:[C_i] gets a *local +projection* stem:[G\!\restriction_{C_i}], and the block types iff every +projection is well-typed and the projections are *compatible* (the +deadlock-freedom premise). + +[stem] +++++ +\frac{ +\forall i.\ \Gamma\mid\Delta_i \vdash G\!\restriction_{C_i} : \mathsf{Total} \dashv \Delta_i' \quad +\Delta = \textstyle\sum_i \Delta_i \quad +\mathsf{compatible}(G\!\restriction_{C_1},\dots,G\!\restriction_{C_n}) +}{ +\Gamma\mid\Delta \vdash \mathtt{sync}(C_1,\dots,C_n)\{G\} : \mathsf{Total} \dashv \textstyle\sum_i\Delta_i' +}\ \textsc{(T-Sync)} +++++ + +The premise stem:[\mathsf{compatible}(\cdots)] is the *deadlock-freedom* +side-condition of session types (every send has a matching receive; no cyclic +wait): each actor's projection is dual to its partners' at the barrier. The +linear zone is *split* across actors (each consumes a disjoint sub-multiset of +resources) and re-merged at the residual — so two actors cannot both consume +the same egg. A projection mismatch or a cyclic wait fails this premise ⇒ +*`DEADLOCK`* (well-formedness; not in the keystone error list but emitted by +`T-Sync`). When the block carries `proving @e`, `T-Proving` (§5.2) supplies +the additional witness conjunct. + +=== 6.5 Echo — postulated witness binder via `proving` + +An `echo @e on A.μ;` declaration enters stem:[\Gamma] a *postulated oracle*: a +binder for a proposition about the physical world on modality stem:[\mu] of +appliance stem:[A]. It is not derivable; it is a hypothesis the sensor +pipeline discharges at run time. + +[stem] +++++ +\frac{\Gamma \vdash A : \mathsf{Appliance}[\mathsf{caps}] \quad \mu \in \mathsf{modalities}(A)} + {\Gamma \vdash \mathtt{echo}\ @e\ \mathtt{on}\ A.\mu; \;\rightsquigarrow\; \Gamma,\ @e : \mathsf{Echo}[\mu]\ \mathbf{postulated}}\ \textsc{(T-Echo)} +++++ + +The `proving @e` rule (`T-Proving`, §5.2) is the *eliminator*: it consumes the +postulate to certify barrier release, producing the existential +stem:[\exists t.\,@e(t)]. The proven/postulated boundary becomes the run-time +trust boundary (constitution §6): at HAL lowering each `Echo[μ]` must bind to +a *local* sensor (Zero Scams), or the program does not lower to that device. A +`proving @e` whose `@e` is undeclared or whose modality the appliance lacks ⇒ +*`UNWITNESSED_SYNC`*. + +=== 6.6 Ceremonial — reader effect commuting with linearity + +A `ceremony C { … }` enters stem:[\Gamma] as an ambient *reader effect*; a +mission `under C` runs the choreography inside it. A ceremony re-paces and +re-prioritises but is *forbidden* to change the linear resource count — the +commutation side condition. + +[stem] +++++ +\frac{ +\Gamma, \mathsf{ceremony}{=}C \mid \Delta \vdash \mathit{body} : \mathsf{Total} \dashv \Delta' \quad +\underbrace{\mathrm{linres}(\Delta) = \mathrm{linres}(\Delta'_{\!C}) \;\text{iff}\; \mathrm{linres}(\Delta')}_{\text{ceremony commutes with linearity}} +}{ +\Gamma \mid \Delta \vdash \mathtt{recipe}\ R\ \mathtt{under}\ C\ \{\mathit{body}\} : \mathsf{Total} \dashv \Delta' +}\ \textsc{(T-Ceremony)} +++++ + +The side condition says: running the body *with* ceremony stem:[C] consumes +exactly the same multiset of grade-1 resources as running it without — the +reader effect commutes with the linear effect (COMMENTARY: "ceremony effects +commute with linear-resource effects"). A ceremony may *tighten* a step's +effective `max_duration` (re-pacing) and raise its `alert_level`/`priority`, +but it may not duplicate or discard a resource. A ceremony that did so would +violate the side condition ⇒ *`CEREMONY_NONCOMMUTE`* (well-formedness). Style +modifiers (§3.4) and ceremony pacing compose: both may only shrink (envelope +shrink for style, time-budget shrink for ceremony), never widen. + +=== 6.7 Primitive + +The Primitive type is the dimensioned-quantity layer of §2 (rules `T-Lit`, +`T-QMul`, `T-QDiv`, `T-QAdd`, `T-Cmp`). It is the numeric carrier for every +other rule's interval arithmetic. Its only proof obligation is +dimension-preservation (§2.3 lemma); its only diagnostic is `DIM_MISMATCH`. + +== 7. Well-formedness conditions and the error catalogue + +=== 7.1 Well-formedness (checked before typing) + +A program is *well-formed* iff: + +. *Dimensions resolve.* Every literal's unit suffix has a defined + base-conversion and dimension; every `range/slope/reach` interval has + endpoints of the capability's declared dimension. (else `DIM_MISMATCH`) +. *Envelope sanity.* In every envelope each interval has + stem:[\ell_d \le h_d]; soft limits lie at or below the corresponding hard + limit; the danger set is non-empty for any material a recipe heats. +. *Style monotonicity.* Every `chef … style σ` satisfies + stem:[\sigma(\mathcal E)\subseteq\mathcal E] per capability. (else + `STYLE_WIDENS`) +. *Echo modality.* Every `echo @e on A.μ` has stem:[\mu\in\mathsf{modalities}(A)]. +. *Mandatory triple.* Every step has a `max_duration` (Time-dimensioned, + stem:[>0]), an `until` condition, and an `on_fail`. (else `MISSING_MAXDUR` + / `MISSING_UNTIL` / `MISSING_ONFAIL`) +. *Grade saturation at mission end.* The residual linear zone has every + grade-1 resource demoted to grade 0. (else `LEAK`) +. *Ceremony commutation.* `under C` satisfies the §6.6 side condition. (else + `CEREMONY_NONCOMMUTE`) +. *Choreographic compatibility.* Every `sync` body's projections are + compatible. (else `DEADLOCK`) + +=== 7.2 The error catalogue + +The exact diagnostic names the checker emits, grouped by the premise/obligation +that fails: + +[cols="1,2,3", options="header"] +|=== +| Error | Fails | Meaning +| *`CURDLE`* | `T-Safe` (3) | a heat step reaches/exceeds a material's *hard* thermal ruin limit (milk `curdle`). +| *`SCORCH`* | `T-Safe` (3) | a heat/bake step reaches a material's hard thermal limit (egg `scorch`, surface `burn`). +| *`MELT_TOOL`* | `T-Safe` (3) | a *tool* is exposed at/above its `melt`/`char` hard limit (silicone spatula). +| *`OVER_TORQUE`* | `T-Safe` (2) | requested torque exceeds the appliance's (post-style) torque range (arm `> 8Nm`). +| *`OVER_REACH`* | `T-Safe` (2) | requested reach exceeds the appliance's (post-style) reach range (arm `> 85cm`). +| *`NO_CAPABILITY`* | `T-Safe` (1) | the controlling appliance lacks the requested verb (hob asked to `Whisk`). +| *`DOUBLE_USE`* | `Chk-Double` | a resource is consumed twice (grade-0 in consumable position; stem:[1\ne1+1]). +| *`LEAK`* | `Chk-Leak` | a grade-1 resource is unused at mission end. +| *`MISSING_ONFAIL`* | `T-OnFail` | a step omits its `on_fail` handler. +| *`UNWITNESSED_SYNC`*| `T-Proving` | a `proving @e` names an undeclared echo or one whose modality the appliance lacks. +| *`DIM_MISMATCH`* | `T-Cmp`/`T-QAdd`/wf | a dimensioned operation mixes incompatible dimensions (compare `82C` with `5min`). +|=== + +Auxiliary diagnostics (emitted by the same rules, outside the keystone list): +`MISSING_MAXDUR`, `MISSING_UNTIL`, `OUT_OF_RANGE` (premise (2) on a non-torque/ +reach dimension, e.g. slope or rpm), `STYLE_WIDENS`, `CEREMONY_NONCOMMUTE`, +`DEADLOCK`, and the *warning* (never blocking) `SCALD_WARN` for crossing a +soft limit. + +== 8. Soundness: well-typed recipes do not reach a ruin phase + +The whole type system exists to make one theorem true. We state it precisely, +in the shape `NoCurdle.agda` mechanises. + +[NOTE] +.Theorem (Physical Safety / "no ruin") +==== +Let stem:[R] be a recipe with stem:[\Gamma\mid\Delta\vdash R : \mathsf{Total}\dashv\Delta']. +Then for *every* run of stem:[R] under the postulated echo oracles and the +chef-style/controller contracts: + +. *(No ruin)* No material or tool ever enters a ruin typestate + (`CURDLED`, `SCORCHED`, `BURNT`, melted). Equivalently: at every step and + every reachable trajectory point, the exposed quantity is *strictly* below + every hard limit of every touched material/tool — + stem:[\forall \mathcal M \in \mathsf{touched}.\ \mathsf{req} \prec \mathsf{danger}(\mathcal M)]. +. *(No over-actuation)* No appliance is ever driven outside its post-style + capability envelope — + stem:[\mathsf{req}\subseteq\sigma(\mathcal E^A_V)] at every step. +. *(Linearity)* Each grade-1 resource is consumed exactly once: no + duplication, no leak. +. *(Termination & totality)* Every step terminates within its `max_duration` + fuel and returns a value in + stem:[\{\mathsf{success}\}\uplus\{\mathsf{aborted},\mathsf{recovered},\mathsf{warmed}\}]; + the recipe is a total function. +. *(Witness)* Every successful `sync … proving @e` carries an existential + sensor witness stem:[\exists t.\,@e(t)]. +==== + +*Proof shape (progress + preservation, relative to the trusted base).* By +induction on the typing derivation. + +* *Preservation.* `T-Safe` is the only rule that advances a material's + typestate, and its premise (3) (stem:[\mathsf{req}\prec\mathsf{danger}]) + forbids any transition into a ruin state (`L-NoRuin`, §6.2). Hence no + well-typed step destroys the "all touched materials clear of every hard + limit" invariant. Premise (2) preserves the "no over-actuation" invariant. + Context split/merge (§1.4) preserves the grade bookkeeping: a grade-1 + resource consumed by `T-Safe`/`T-Bind` is demoted to 0 and cannot be + re-consumed. +* *Progress.* Every step has the mandatory triple (§5.2), so the `until`-loop + has decreasing fuel (`T-MaxDur` ⇒ termination), an inhabited failure branch + (`T-OnFail` ⇒ no stuck step), and — where present — a witnessed barrier + release (`T-Proving`). The result type is the total sum + stem:[\{success\}\uplus\{aborted,recovered,warmed\}]. +* *Linearity* falls out of grade saturation (§7.1.6): the residual zone + having every grade-1 resource demoted to 0, with no contraction available, + is exactly "consumed once, no leak." + +*Trusted base.* The theorem holds *relative to* two classes of postulate the +type system deliberately does not discharge (constitution §6, COMMENTARY +§Echo): (a) the *echo soundness* postulates — each sensor classifier reports +truthfully (`temp-at`, `white-set-at`, `viscosity-at` in the Agda files); and +(b) the *controller contracts* — each style/`GENTLE` controller honours its +slope envelope (`gentle-bounded`). At HAL lowering each is discharged by a +local sensor binding / a certified device envelope, or the program does not +lower to that device. Within that trusted base, *a well-typed KitchenSpeak +recipe cannot curdle the milk, scorch the egg, bake the spatula, or +over-torque the arm — those programs do not type-check.* This is the +recipe-level generalisation that `proofs/agda/NoCurdle.agda` mechanises +(`tropical-safe`, `served-is-safe`), and it is the formal content of the +constitution's slogan: **make illegal physical states unrepresentable.** diff --git a/examples/v2/mayonnaise.ks b/examples/v2/mayonnaise.ks new file mode 100644 index 0000000..3a2fe29 --- /dev/null +++ b/examples/v2/mayonnaise.ks @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// Linear + Dyadic + Choreographic + Echo + robotics: emulsify, then a robot plates it. +material Mayo { phase: EMULSION } +echo @plated on Arm.visual; + +recipe Mayonnaise { + use Egg as yolk; + use Oil as oil; + (yolk, oil) <~> Blender -> Mayo; + sync(Blender) { + Blender.Whisk(Mayo, speed: 800rpm) + until ~ viscosity >= 1Pas + max_duration 3min + on_fail RECOVER; + } + sync(Arm) proving @plated { + Arm.Plate(Mayo, torque: 2Nm, reach: 30cm) + until @plated == true + max_duration 1min + on_fail ABORT; + } +} diff --git a/examples/v2/negative/bake-spatula.ks b/examples/v2/negative/bake-spatula.ks new file mode 100644 index 0000000..f985ddf --- /dev/null +++ b/examples/v2/negative/bake-spatula.ks @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: MELT_TOOL +recipe BakeSpatula { + use Steak as s; + sync(Oven) { + Oven.Bake(s, Spatula, target: 300C) + until ~ temp >= 290C max_duration 20min on_fail ABORT; + } +} diff --git a/examples/v2/negative/curdle-milk.ks b/examples/v2/negative/curdle-milk.ks new file mode 100644 index 0000000..057821d --- /dev/null +++ b/examples/v2/negative/curdle-milk.ks @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: CURDLE +recipe CurdleMilk { + use Milk as m; + sync(Hob) { + Hob.Heat(m, target: 95C, slope: GENTLE) + until ~ temp >= 90C max_duration 5min on_fail ABORT; + } +} diff --git a/examples/v2/negative/curdle-via-until.ks b/examples/v2/negative/curdle-via-until.ks new file mode 100644 index 0000000..e4390cb --- /dev/null +++ b/examples/v2/negative/curdle-via-until.ks @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: CURDLE +// No explicit target: — the danger is hidden in the `until` guard (95C >= curdle 82C). +// The checker's soundness backstop must still catch it. +recipe CurdleViaUntil { + use Milk as m; + sync(Hob) { + Hob.Heat(m, slope: GENTLE) until ~ temp >= 95C max_duration 5min on_fail ABORT; + } +} diff --git a/examples/v2/negative/dim-mismatch.ks b/examples/v2/negative/dim-mismatch.ks new file mode 100644 index 0000000..ce11208 --- /dev/null +++ b/examples/v2/negative/dim-mismatch.ks @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: DIM_MISMATCH +recipe DimMismatch { + use Water as w; + sync(Hob) { + Hob.Heat(w, target: 5min) until ~ temp >= 80C max_duration 8min on_fail ABORT; + } +} diff --git a/examples/v2/negative/double-egg.ks b/examples/v2/negative/double-egg.ks new file mode 100644 index 0000000..fa469b0 --- /dev/null +++ b/examples/v2/negative/double-egg.ks @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: DOUBLE_USE +recipe DoubleEgg { + use Egg as e; + use Vinegar as v; + sync(Hob) proving @ws { + Hob.Poach(e, v, depth: 4cm) until @ws == true max_duration 4min on_fail RECOVER; + Hob.Poach(e, v, depth: 4cm) until @ws == true max_duration 4min on_fail RECOVER; + } +} +echo @ws on Hob.visual; diff --git a/examples/v2/negative/missing-onfail.ks b/examples/v2/negative/missing-onfail.ks new file mode 100644 index 0000000..65d548a --- /dev/null +++ b/examples/v2/negative/missing-onfail.ks @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: MISSING_ONFAIL +recipe MissingOnFail { + use Milk as m; + sync(Hob) { + Hob.Heat(m, target: 60C) until ~ temp >= 58C max_duration 5min; + } +} diff --git a/examples/v2/negative/no-capability.ks b/examples/v2/negative/no-capability.ks new file mode 100644 index 0000000..e77a380 --- /dev/null +++ b/examples/v2/negative/no-capability.ks @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: NO_CAPABILITY +recipe FridgeHeat { + use Milk as m; + sync(Fridge) { + Fridge.Heat(m, target: 60C) until ~ temp >= 58C max_duration 5min on_fail ABORT; + } +} diff --git a/examples/v2/negative/over-reach.ks b/examples/v2/negative/over-reach.ks new file mode 100644 index 0000000..bb3c85d --- /dev/null +++ b/examples/v2/negative/over-reach.ks @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: OVER_REACH +recipe OverReach { + use Steak as p; + sync(Arm) { + Arm.Move(p, torque: 3Nm, reach: 120cm) + until ~ position >= 1cm max_duration 10s on_fail ABORT; + } +} diff --git a/examples/v2/negative/over-torque.ks b/examples/v2/negative/over-torque.ks new file mode 100644 index 0000000..983ae6f --- /dev/null +++ b/examples/v2/negative/over-torque.ks @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: OVER_TORQUE +recipe OverTorque { + use Steak as p; + sync(Arm) { + Arm.Move(p, torque: 12Nm, reach: 30cm) + until ~ position >= 1cm max_duration 10s on_fail ABORT; + } +} diff --git a/examples/v2/negative/unwitnessed-sync.ks b/examples/v2/negative/unwitnessed-sync.ks new file mode 100644 index 0000000..c5254c6 --- /dev/null +++ b/examples/v2/negative/unwitnessed-sync.ks @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// EXPECT: UNWITNESSED_SYNC +echo @white_set on Hob.visual; +recipe Unwitnessed { + use Egg as e; + use Vinegar as v; + sync(Hob) { + Hob.Poach(e, v) until @white_set == true max_duration 4min on_fail RECOVER; + } +} diff --git a/examples/v2/poached-egg.ks b/examples/v2/poached-egg.ks new file mode 100644 index 0000000..ecdf567 --- /dev/null +++ b/examples/v2/poached-egg.ks @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// v2 port of the canonical poached egg: Linear + Tropical + Echo + Ceremonial. +echo @water_shimmer on Hob.visual; +echo @white_set on Hob.visual; +ceremony Breakfast { pace: BRISK; alert: 4; priority: TIMING; } + +recipe PoachedEgg under Breakfast { + use Water as w; + use Egg as e; + use Vinegar as v; + sync(Hob) proving @water_shimmer { + Hob.Heat(w, target: 82C, slope: GENTLE) + until ~ temp >= 80C + max_duration 8min + on_fail ABORT; + } + sync(Hob) proving @white_set { + Hob.Poach(e, v, depth: 4cm) + until @white_set == true + max_duration 4min + on_fail RECOVER; + } +} diff --git a/examples/v2/robot-plate.ks b/examples/v2/robot-plate.ks new file mode 100644 index 0000000..987e9b4 --- /dev/null +++ b/examples/v2/robot-plate.ks @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// Consumer robotics: an arm plates a portion within its torque + reach envelope. +echo @placed on Arm.visual; +recipe PlateGarnish { + use Steak as portion; + sync(Arm) proving @placed { + Arm.Plate(portion, torque: 3Nm, reach: 40cm) + until @placed == true + max_duration 30s + on_fail ABORT; + } +} diff --git a/examples/v2/scald-milk.ks b/examples/v2/scald-milk.ks new file mode 100644 index 0000000..e6f6fb1 --- /dev/null +++ b/examples/v2/scald-milk.ks @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// Heats milk to just below its curdle envelope. Passes, with a soft 'scald' +// warning at 70C (quality threshold, not a ruin). Change 70C -> 95C to see CURDLE. +recipe ScaldMilk { + use Milk as m; + sync(Hob) { + Hob.Heat(m, target: 70C, slope: GENTLE) + until ~ temp >= 68C + max_duration 5min + on_fail ABORT; + } +} diff --git a/examples/v2/sear-steak.ks b/examples/v2/sear-steak.ks new file mode 100644 index 0000000..7449ea8 --- /dev/null +++ b/examples/v2/sear-steak.ks @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell +// Tool safety + tropical: a silicone spatula (melt 260C) is fine in a 200C sear. +recipe SearSteak { + use Steak as s; + sync(Hob) { + Hob.Sear(s, Spatula, target: 200C, slope: MODERATE) + until ~ temp >= 190C + max_duration 6min + on_fail ABORT; + } +} diff --git a/proofs/Makefile b/proofs/Makefile new file mode 100644 index 0000000..fc8c774 --- /dev/null +++ b/proofs/Makefile @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MPL-2.0 +# KitchenSpeak proof build harness. +# +# The KitchenSpeak invariant is "Agda-Proven": this Makefile is the +# entry point that discharges it. It type-checks every proof in the +# core library; a green run means zero unsolved goals. +# +# Requirements: +# - Agda 2.6.4+ (e.g. `nix shell nixpkgs#agda`, or distro pkg) +# - agda-stdlib 2.3+ (registered in your ~/.agda/libraries) +# - echo-types + absolute-zero (registered in ~/.agda/libraries) for +# EchoBridge.agda — see proofs/README.adoc and ../decisions/0003. +# +# Usage: +# make # type-check the whole core proof library +# make dough # type-check Dough.agda only +# make poachedegg # type-check PoachedEgg.agda only +# make echobridge # type-check EchoBridge.agda only (needs echo-types) +# make clean # remove Agda interface files + +AGDA ?= agda +AGDA_FLAGS ?= --library=kitchenspeak +SRC_DIR := agda + +# The core proof library, in dependency-free order. NoCurdle is the v2.0 +# flagship (material safety envelope). Emulsion and Sear are tracked in +# ../ROADMAP.adoc Phase 1c and join this list as they land. EchoBridge +# wires the @ witness onto hyperpolymath/echo-types. +MODULES := Dough PoachedEgg NoCurdle EchoBridge + +.PHONY: all clean $(MODULES) + +all: $(MODULES) + +# Per-module targets (lower-cased) so `make poachedegg` works. +dough: Dough +poachedegg: PoachedEgg +nocurdle: NoCurdle +echobridge: EchoBridge + +$(MODULES): + cd $(SRC_DIR) && $(AGDA) $(AGDA_FLAGS) $@.agda + +clean: + find $(SRC_DIR) -name '*.agdai' -delete diff --git a/proofs/agda/Dough.agda b/proofs/agda/Dough.agda index a9ee6fa..72a8d7d 100644 --- a/proofs/agda/Dough.agda +++ b/proofs/agda/Dough.agda @@ -175,7 +175,12 @@ bind-dough _ _ = mkDough KNEADABLE -- *assumes* its values exist and discharges the surrounding control -- flow. See COMMENTARY.adoc §Echo for the discussion of postulated -- oracles and the proven/postulated boundary. - +-- +-- AXIOM: postulated echo-oracle (the torque sensor stream) — the +-- physical world, not a derivable term. The proof trusts the sensor +-- and discharges the surrounding control flow; a production lowering +-- replaces it with a verified HAL binding (§8(a)). Trusted base +-- contribution: 1 sensor postulate. postulate viscosity-at : Minutes → Torque-dNm diff --git a/proofs/agda/EchoBridge.agda b/proofs/agda/EchoBridge.agda new file mode 100644 index 0000000..2dd2cf4 --- /dev/null +++ b/proofs/agda/EchoBridge.agda @@ -0,0 +1,150 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell +-- +-- ===================================================================== +-- KitchenSpeak — Echo Bridge to the echo-types library +-- ===================================================================== +-- +-- STATUS (per decisions/0004-echo-attaches-to-linear-dyadic.adoc): +-- TEMPORARY SMOKE-TEST BRIDGE — not the canonical Echo attachment. +-- Q1 is decided B-now / C-later / A-shim: echo-types attaches *primarily* +-- to KitchenSpeak's Linear/Dyadic consumption types (irreversible +-- transformation with retained provenance: Flour ⊗ Water → Dough), with +-- the @/sensor-witness demoted to a secondary adapter/example. The Boolean +-- classifier below is the degenerate fiber the global Echo principle warns +-- against (a yes/no tag, not retained-loss lineage); it stands only as a +-- smoke-test until the real lineage/residue attachment is built against +-- echo-types' decorated modules (EchoLinear / EchoResidue / +-- EchoResidueTaxonomy), which requires the echo-types repo in scope. +-- Do NOT treat this Bool/@ bridge as canon. +-- +-- KitchenSpeak's Echo (@) type is a *postulated-oracle witness*: a +-- dependent pair of a sensor sample time and a proof the sensor met its +-- threshold there (Dough.agda's `Witness`, PoachedEgg.agda's +-- `ShimmerWitness` / `WhiteWitness`). +-- +-- The hyperpolymath/echo-types library (module `Echo`) gives the +-- canonical, mechanised form of exactly this structure — the *fiber*: +-- +-- Echo : (f : A → B) → B → Set +-- Echo f y = Σ A (λ x → f x ≡ y) +-- +-- i.e. "which inputs map to y", the proof-relevant record of structured +-- loss (a non-injective classifier forgets *which* sample fired, but the +-- Echo retains that one did). This module wires KitchenSpeak's Echo type +-- onto that library type so the planned type checker (ROADMAP Phase 3) +-- and the proofs share a single, mechanised notion of Echo rather than +-- an ad-hoc Σ. See decisions/0003-echo-types-dependency.adoc. +-- +-- The reconciliation. KitchenSpeak's witness is a fiber over a +-- *predicate* (`sensor t ≥ thr`); echo-types' Echo is a fiber over an +-- *equality* (`f x ≡ y`). We bridge by viewing each threshold oracle as +-- a Boolean *classifier* `fired s thr t = ⌊ s t ≥? thr ⌋`, whose Echo +-- over `true` is the KitchenSpeak witness. The classifier is the lossy +-- function; the Echo is its structured-loss witness. +-- +-- --------------------------------------------------------------------- +-- STATUS: MACHINE-CHECKED (2026-06-12). This module now typechecks against +-- the real echo-types library (module `Echo`), with echo-types + +-- absolute-zero + agda-stdlib v2.3 registered, under Agda 2.6.3. The +-- earlier "hand-verified by careful reading" caveat is retired: the +-- `Echo` import and the witness⇔echo bridge below are confirmed by the +-- type checker, not by eye. The flag stance (this module is left at +-- Agda's default discipline, importing the `--safe --without-K` `Echo`) +-- is confirmed: a default-discipline module may import the `--safe +-- --without-K` `Echo`. +-- +-- Reproduce (matching echo-types' own CI toolchain, Agda 2.6.3): +-- LC_ALL=C.UTF-8 agda --no-libraries \ +-- -i . -i /proofs/agda -i /src EchoBridge.agda +-- (The `make echobridge` target uses kitchenspeak.agda-lib, whose `--` +-- comments require Agda ≥ 2.6.4; on the 2.6.3 CI toolchain use the +-- explicit `-i` form above. See proofs/README.adoc.) +-- --------------------------------------------------------------------- + +module EchoBridge where + +open import Data.Bool using (Bool; true; false) +open import Data.Nat using (ℕ; _≥_; _≥?_) +open import Data.Product using (Σ; _,_; Σ-syntax) +open import Data.Empty using (⊥-elim) +open import Relation.Nullary using (yes; no) +open import Relation.Nullary.Decidable using (⌊_⌋) +open import Relation.Binary.PropositionalEquality using (_≡_; refl) + +-- The Echo (fiber) type from hyperpolymath/echo-types (module `Echo`): +-- Echo f y = Σ A (λ x → f x ≡ y) +open import Echo using (Echo) + + +-- ===================================================================== +-- § 1. The threshold oracle as a Boolean classifier. +-- ===================================================================== +-- +-- A KitchenSpeak echo-oracle samples a sensor `s : ℕ → ℕ` (reading per +-- minute) and asks whether it has met a threshold `thr`. As a function +-- into Bool this is the (generally non-injective) classifier whose Echo +-- carries the structured loss: many minutes map to `true`, and the Echo +-- records that *some* qualifying minute exists without pinning which. + +fired : (ℕ → ℕ) → ℕ → ℕ → Bool +fired s thr t = ⌊ s t ≥? thr ⌋ + + +-- ===================================================================== +-- § 2. The two presentations of a KitchenSpeak Echo witness. +-- ===================================================================== +-- +-- ThresholdWitness — the form used inline in Dough.agda / PoachedEgg.agda +-- (`Σ[ t ] sensor t ≥ thr`). +-- SensorEcho — the echo-types form: the fiber `Echo (fired …) true`. + +ThresholdWitness : (ℕ → ℕ) → ℕ → Set +ThresholdWitness s thr = Σ[ t ∈ ℕ ] s t ≥ thr + +SensorEcho : (ℕ → ℕ) → ℕ → Set +SensorEcho s thr = Echo (fired s thr) true + + +-- ===================================================================== +-- § 3. The bridge: KitchenSpeak's @-witness IS an echo-types Echo. +-- ===================================================================== +-- +-- The two lemmas establish `s t ≥ thr ⟺ fired s thr t ≡ true`, lifted +-- to the Σ/Echo level. Stating the goal with `⌊ s t ≥? thr ⌋` (which is +-- `fired s thr t` by definition) keeps the `with`-abstraction robust. + +≥⇒fired : ∀ (s : ℕ → ℕ) (thr t : ℕ) → s t ≥ thr → ⌊ s t ≥? thr ⌋ ≡ true +≥⇒fired s thr t pf with s t ≥? thr +... | yes _ = refl +... | no ¬p = ⊥-elim (¬p pf) + +fired⇒≥ : ∀ (s : ℕ → ℕ) (thr t : ℕ) → ⌊ s t ≥? thr ⌋ ≡ true → s t ≥ thr +fired⇒≥ s thr t eq with s t ≥? thr +... | yes p = p +... | no _ with eq +... | () + +-- KitchenSpeak threshold witness → echo-types Echo. +witness⇒echo : ∀ {s thr} → ThresholdWitness s thr → SensorEcho s thr +witness⇒echo {s} {thr} (t , pf) = t , ≥⇒fired s thr t pf + +-- echo-types Echo → KitchenSpeak threshold witness. +echo⇒witness : ∀ {s thr} → SensorEcho s thr → ThresholdWitness s thr +echo⇒witness {s} {thr} (t , eq) = t , fired⇒≥ s thr t eq + + +-- ===================================================================== +-- § 4. Usage from the recipe proofs. +-- ===================================================================== +-- +-- Dough.agda and PoachedEgg.agda keep their inline `Σ[ t ] sensor t ≥ +-- thr` witnesses (so their structural-recursion proofs are unchanged), +-- and may present them as echo-types Echoes through `witness⇒echo`: +-- +-- viscosity-echo : Witness → SensorEcho viscosity-at kneaded-threshold +-- viscosity-echo = witness⇒echo +-- +-- The planned KitchenSpeak type checker (ROADMAP Phase 3) takes +-- `Echo (fired sensor thr) true` as the canonical typing of an `@` +-- witness, with EchoTypes.jl as the finite-domain runtime/HAL model. diff --git a/proofs/agda/NoCurdle.agda b/proofs/agda/NoCurdle.agda new file mode 100644 index 0000000..01fe7f1 --- /dev/null +++ b/proofs/agda/NoCurdle.agda @@ -0,0 +1,222 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell +-- +-- ===================================================================== +-- KitchenSpeak — No-Curdle Proof (Agda) [v2.0 flagship] +-- ===================================================================== +-- +-- Lowers `examples/v2/scald-milk.ks` and discharges the v2.0 keystone: +-- the MATERIAL safety envelope. Where PoachedEgg.agda proved a Tropical +-- *appliance/boil* envelope (`tropical-safe`), this proves a Tropical +-- *material* envelope — milk's curdle point — is never reached. It is the +-- machine-checked form of the checker's CURDLE rule (check.ml / +-- docs/spec/0003-type-system.adoc §physical-safety). +-- +-- recipe ScaldMilk { +-- use Milk as m; +-- sync(Hob) { +-- Hob.Heat(m, target: 70C, slope: GENTLE) +-- until ~ temp >= 68C +-- max_duration 5min +-- on_fail ABORT; +-- } +-- } +-- +-- Milk (stdlib_ks.ml): soft scald 70C, HARD curdle 82C. The recipe's +-- GENTLE setpoint is 70C, strictly below the curdle envelope. The headline +-- theorem `no-curdle` shows the controlled trajectory never reaches 82C, +-- and `stays-fresh` shows the milk's phase typestate never becomes CURDLED. +-- +-- Obligations discharged: +-- 1. Termination — `warm` is structurally recursive on a fuel parameter. +-- 2. Safety — every WarmResult is Warmed or Aborted (on_fail ABORT). +-- 3. Echo witness — a Warmed result carries an existential reach witness. +-- 4. Material — NEW: `no-curdle` + `stays-fresh`. The milk never +-- crosses its hard curdle limit; its phase stays FRESH. +-- +-- The soft scald threshold (70C) MAY be reached — that is a quality warning +-- in the checker, not a ruin — and the proof deliberately does NOT forbid it. +-- +-- --------------------------------------------------------------------- +-- Agda 2.6.4+ / stdlib 2.x. As with PoachedEgg.agda we do NOT use +-- {-# OPTIONS --safe #-}: the sensor stream and the GENTLE controller +-- contract are physical-world postulates a production lowering replaces +-- with HAL bindings. +-- --------------------------------------------------------------------- + +module NoCurdle where + +open import Data.Nat + using (ℕ; zero; suc; _≥_; _≥?_; _≤_; _<_; _= 68C` guard. +reach-threshold : Celsius +reach-threshold = 68 + +-- The GENTLE setpoint cap (`target: 70C`). +target-cap : Celsius +target-cap = 70 + +-- Milk's SOFT scald threshold (quality; reaching it only warns). +scald-threshold : Celsius +scald-threshold = 70 + +-- Milk's HARD curdle threshold (ruin; reaching it is the CURDLE error). +curdle-threshold : Celsius +curdle-threshold = 82 + +-- The setpoint sits strictly below the curdle envelope — a configuration +-- fact about the chosen recipe, computed by the decision procedure. +cap +-- +-- ===================================================================== +-- KitchenSpeak — Poached Egg Proof (Agda) +-- ===================================================================== +-- +-- Lowers `examples/poached-egg.ks`, the minimum KitchenSpeak program +-- that exercises all three of Linear (the egg), Tropical (the water +-- temperature), and Echo (the visual witnesses) together. It is the +-- second proof in the core library after `Dough.agda`, and the first +-- to discharge a *Tropical* refinement obligation. +-- +-- The source program (see examples/poached-egg.ks) has two sync blocks: +-- +-- sync(Delia_Hob) proving @water_shimmer { +-- Delia_Hob.Heat(target: 82C, slope: GENTLE) +-- until ~ temp >= 80C +-- max_duration 8min +-- on_fail ABORT; +-- } +-- sync(Delia_Hob) proving @white_set { +-- Delia_Hob.Poach(Egg, acidulant: Vinegar, depth: 4cm) +-- until @white_set == true +-- max_duration 4min +-- on_fail RECOVER; +-- } +-- +-- Obligations discharged (per COMMENTARY.adoc): +-- +-- 1. Termination — both `heat` and `poach` are structurally +-- recursive on a fuel parameter. Agda's termination +-- checker accepts them; no separate theorem needed. +-- +-- 2. Safety — every branch of `heat` returns a HeatResult +-- (Shimmered or Aborted, the `on_fail ABORT` image) +-- and every branch of `poach` returns a PoachResult +-- (Set or Recovered, the `on_fail RECOVER` image). +-- Shown by `heat-exhaustive` / `poach-exhaustive`. +-- +-- 3. Echo witness — a successful Heat carries an existential witness +-- that @water_shimmer fired; a successful Poach +-- carries one that @white_set fired. Shown by +-- `shimmer-gives-witness` / `white-gives-witness`. +-- +-- 4. Tropical — NEW relative to Dough.agda. The GENTLE controller +-- never crosses the forbidden boil envelope: at the +-- shimmer time the water is in [80C, 82C], hence +-- strictly below boiling. Shown by `tropical-safe`. +-- +-- Linearity (Egg, Vinegar consumed exactly once) is enforced by +-- inspection of signatures, exactly as in Dough.agda §3: `poach` +-- consumes both Egg and Vinegar and returns neither, and no function +-- in this file duplicates either input. A QTT-Agda port would make +-- this machine-checked without changing the proof shape. +-- +-- --------------------------------------------------------------------- +-- Agda version assumed: 2.6.4+ with stdlib 2.0+. +-- Pragma notes: +-- - We do NOT use {-# OPTIONS --safe #-}: §4 and §5 introduce +-- physical-world postulates (the sensor streams and the GENTLE +-- controller envelope) that a production lowering would replace +-- with HAL bindings. +-- --------------------------------------------------------------------- + +module PoachedEgg where + +open import Data.Nat + using (ℕ; zero; suc; _≥_; _≥?_; _≤_; _<_; _= 80C` threshold, per SPEC / the .ks source. +shimmer-threshold : Celsius +shimmer-threshold = 80 + +-- The Heat action's GENTLE setpoint cap (`target: 82C`). The GENTLE +-- slope is configured so the controller approaches, but never +-- overshoots, this cap. See the postulate in §5. +target-cap : Celsius +target-cap = 82 + +-- The forbidden envelope: a rolling boil at standard pressure. The +-- Tropical obligation is that the trajectory never reaches it. +boil-envelope : Celsius +boil-envelope = 100 + +-- The Heat action's wall-clock budget (`max_duration 8min`). +heat-budget : Minutes +heat-budget = 8 + +-- The Poach action's wall-clock budget (`max_duration 4min`). +poach-budget : Minutes +poach-budget = 4 + +-- The target cap sits strictly below the boil envelope. This is a +-- configuration fact about the chosen setpoint, computed by the +-- decision procedure rather than postulated. +cap *) +(* + KitchenSpeak v2.0 — Abstract Syntax Tree. + + This is the binding AST shape from docs/design/0001-v2-constitution.adoc §4. + Field names are prefixed per-type to avoid OCaml's record-field disambiguation + pitfalls. Every quantity is normalised to a base unit at parse time (see + units.ml), so the checker compares magnitudes directly within a dimension. +*) + +(* ── Dimensions (Primitive type / units of measure) ───────────────────────── *) +type dim = + | Temperature + | Time + | Mass + | Torque + | AngularRate + | Length + | Power + | Viscosity + | TempRate (* temperature / time, e.g. C/s — for thermal slopes *) + | Dimensionless + | DUnknown of string + +type quantity = { q_mag : float; q_dim : dim } (* q_mag is in the base unit *) + +(* ── Material / tool safety envelopes (the keystone) ───────────────────────── *) +type limit_kind = Soft | Hard + +type limit = { l_kind : limit_kind; l_name : string; l_qty : quantity } + +type envelope = { e_thermal : limit list; e_mech : limit list; e_chem : limit list } + +type material = { + m_name : string; + m_phase : string; + m_env : envelope; + m_is_tool : bool; + m_tool_material : string option; (* e.g. Some "SILICONE" for a spatula *) +} + +(* ── Appliance capability envelopes ───────────────────────────────────────── *) +type capability = { + c_verb : string; + c_quantity : dim; (* the primary controlled dimension *) + c_lo : quantity; + c_hi : quantity; + c_slope : (quantity * quantity) option; (* rate envelope, if any *) + c_reach : (quantity * quantity) option; (* spatial envelope, if any *) + c_consumes : bool; (* does the verb consume its target? *) + c_modalities : string list; (* sensor modalities offered, e.g. visual *) +} + +type appliance = { a_name : string; a_caps : capability list } + +type chef = { ch_name : string; ch_controls : string; ch_style : string } + +type echo = { ec_name : string; ec_appliance : string; ec_modality : string } + +type ceremony = { ce_name : string; ce_settings : (string * string) list } + +(* ── Steps & mission body ─────────────────────────────────────────────────── *) +type cmp = Ge | Le | Gt | Lt | Eq + +type cond = + | TropCond of string * cmp * quantity (* ~ metric cmp qty *) + | EchoCond of string * cmp * string (* @ echo cmp value *) + +type fail = Abort | Recover | Warm + +type argval = AQty of quantity | ATag of string | AIdent of string + +type arg = + | APos of string (* positional target (a resource or tool name) *) + | ANamed of string * argval (* name : value *) + +type step = { + s_actor : string; + s_verb : string; + s_args : arg list; + s_until : cond option; + s_maxdur : quantity option; + s_onfail : fail option; + s_line : int; +} + +type sync_block = { + sy_actors : string list; + sy_proving : string option; + sy_steps : step list; + sy_line : int; +} + +type bind_step = { b_inputs : string list; b_actor : string; b_output : string; b_line : int } + +type use_decl = { u_material : string; u_as : string; u_line : int } + +type rstmt = + | Sync of sync_block + | Bind of bind_step + | Use of use_decl + +type decl = + | DMaterial of material + | DTool of material + | DAppliance of appliance + | DChef of chef + | DEcho of echo + | DCeremony of ceremony + +type recipe = { r_name : string; r_under : string option; r_body : rstmt list } + +type program = { p_decls : decl list; p_recipes : recipe list } + +(* ── Display helpers ──────────────────────────────────────────────────────── *) +let dim_name = function + | Temperature -> "Temperature" | Time -> "Time" | Mass -> "Mass" + | Torque -> "Torque" | AngularRate -> "AngularRate" | Length -> "Length" + | Power -> "Power" | Viscosity -> "Viscosity" | TempRate -> "TempRate" + | Dimensionless -> "Dimensionless" | DUnknown u -> "Unknown(" ^ u ^ ")" + +let dim_eq a b = match a, b with + | DUnknown x, DUnknown y -> x = y + | _ -> a = b + +(* Pretty-print a quantity in a readable canonical unit. *) +let show_qty q = + let u = match q.q_dim with + | Temperature -> "C" | Time -> "s" | Mass -> "g" | Torque -> "Nm" + | AngularRate -> "rpm" | Length -> "cm" | Power -> "W" + | Viscosity -> "Pa.s" | TempRate -> "C/s" | Dimensionless -> "" + | DUnknown u -> u in + let m = q.q_mag in + let ms = if Float.equal (Float.round m) m then Printf.sprintf "%.0f" m + else Printf.sprintf "%g" m in + ms ^ u diff --git a/src/compiler/check.ml b/src/compiler/check.ml new file mode 100644 index 0000000..a4feb34 --- /dev/null +++ b/src/compiler/check.ml @@ -0,0 +1,307 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + The KitchenSpeak v2.0 checker: the physically-grounded substructural type + system from docs/spec/0003-type-system.adoc and the safety judgement of + docs/design/0001-v2-constitution.adoc §5. + + For each step `actor.Verb(target, args…)` it discharges: + 1. capability — the controlling appliance has the verb; + 2. device env — every requested quantity is inside the appliance envelope + (after the chef-style modifier, which may only shrink it); + 3. material — the requested exposure is strictly below every HARD limit + of each touched material/tool (soft limits warn); + plus linearity (use-once), the three mandatory constructs, and echo witnesses. +*) + +open Ast + +type sev = Err | Warn +type diag = { d_sev : sev; d_code : string; d_msg : string; d_line : int } + +type env = { + mats : (string, material) Hashtbl.t; (* materials AND tools *) + apps : (string, appliance) Hashtbl.t; + chefs : (string, chef) Hashtbl.t; + echoes : (string, echo) Hashtbl.t; + ceremonies : (string, ceremony) Hashtbl.t; +} + +type res = { mutable live : bool; r_mat : material; r_line : int; from_bind : bool } + +let eps = 1.0e-9 + +(* ── environment construction (stdlib ⊕ file declarations) ─────────────────── *) +let build_env (prog : program) : env = + let e = { mats = Hashtbl.create 64; apps = Hashtbl.create 32; + chefs = Hashtbl.create 16; echoes = Hashtbl.create 16; + ceremonies = Hashtbl.create 16 } in + List.iter (fun m -> Hashtbl.replace e.mats m.m_name m) Stdlib_ks.materials; + List.iter (fun t -> Hashtbl.replace e.mats t.m_name t) Stdlib_ks.tools; + List.iter (fun a -> Hashtbl.replace e.apps a.a_name a) Stdlib_ks.appliances; + List.iter (function + | DMaterial m | DTool m -> Hashtbl.replace e.mats m.m_name m + | DAppliance a -> Hashtbl.replace e.apps a.a_name a + | DChef c -> Hashtbl.replace e.chefs c.ch_name c + | DEcho ec -> Hashtbl.replace e.echoes ec.ec_name ec + | DCeremony ce -> Hashtbl.replace e.ceremonies ce.ce_name ce) + prog.p_decls; + e + +let resolve_actor (e : env) (name : string) : (appliance * string) option = + match Hashtbl.find_opt e.chefs name with + | Some ch -> + (match Hashtbl.find_opt e.apps ch.ch_controls with + | Some a -> Some (a, ch.ch_style) + | None -> None) + | None -> + (match Hashtbl.find_opt e.apps name with + | Some a -> Some (a, "BALANCED") + | None -> None) + +let find_cap (a : appliance) verb = List.find_opt (fun c -> c.c_verb = verb) a.a_caps + +(* device-envelope violation code, by controlled dimension *) +let over_code = function + | Torque -> "OVER_TORQUE" + | AngularRate -> "OVER_SPEED" + | Length -> "OVER_REACH" + | _ -> "OVER_RANGE" + +(* material hard-limit violation code, by limit name *) +let limit_error name = match String.lowercase_ascii name with + | "curdle" -> "CURDLE" + | "scorch" | "burn" -> "SCORCH" + | "melt" | "char" | "anneal" -> "MELT_TOOL" + | "seize" -> "SEIZE" + | "split" -> "SPLIT" + | _ -> "MATERIAL_LIMIT" + +let slope_tag_rate = function + | "GENTLE" -> Some { q_mag = 1.0; q_dim = TempRate } + | "MODERATE" -> Some { q_mag = 3.0; q_dim = TempRate } + | "AGGRESSIVE" -> Some { q_mag = 8.0; q_dim = TempRate } + | _ -> None + +(* the chef style modifier may only SHRINK an envelope's upper bound *) +let style_factor = function + | "PRECISION" -> 0.5 + | "POWER" -> 1.0 + | _ -> 0.8 + +(* limits relevant to a controlled dimension *) +let limits_for_dim (m : material) = function + | Temperature -> m.m_env.e_thermal + | Torque | AngularRate -> m.m_env.e_mech + | _ -> [] + +let check (prog : program) : diag list = + let e = build_env prog in + let diags = ref [] in + let add sev code line msg = diags := { d_sev = sev; d_code = code; d_msg = msg; d_line = line } :: !diags in + + let check_recipe (r : recipe) = + let resources : (string, res) Hashtbl.t = Hashtbl.create 16 in + let bind_outputs = ref [] in + + (match r.r_under with + | Some c when not (Hashtbl.mem e.ceremonies c) -> + add Warn "UNKNOWN_CEREMONY" 0 (Printf.sprintf "recipe '%s' runs under ceremony '%s', which is not declared" r.r_name c) + | _ -> ()); + + let material_of tname = + match Hashtbl.find_opt resources tname with + | Some rr -> Some rr.r_mat + | None -> Hashtbl.find_opt e.mats tname in + + let check_step ~proving (s : step) = + (* mandatory constructs (constitution §3) *) + if s.s_until = None then + add Err "MISSING_UNTIL" s.s_line (Printf.sprintf "step %s.%s has no 'until' postcondition" s.s_actor s.s_verb); + if s.s_maxdur = None then + add Err "MISSING_MAXDUR" s.s_line (Printf.sprintf "step %s.%s has no 'max_duration' (termination witness)" s.s_actor s.s_verb); + if s.s_onfail = None then + add Err "MISSING_ONFAIL" s.s_line (Printf.sprintf "step %s.%s has no 'on_fail' handler" s.s_actor s.s_verb); + + match resolve_actor e s.s_actor with + | None -> add Err "UNKNOWN_ACTOR" s.s_line (Printf.sprintf "actor '%s' is not a declared appliance or chef" s.s_actor) + | Some (app, style) -> + let targets = List.filter_map (function APos n -> Some n | _ -> None) s.s_args in + let named = List.filter_map (function ANamed (n, v) -> Some (n, v) | _ -> None) s.s_args in + (match find_cap app s.s_verb with + | None -> + add Err "NO_CAPABILITY" s.s_line + (Printf.sprintf "appliance '%s' has no capability '%s'" app.a_name s.s_verb) + | Some capb -> + let sf = style_factor style in + + let check_targets_exposure (q : quantity) = + List.iter (fun tname -> + match material_of tname with + | None -> () (* liveness pass reports the bad name *) + | Some m -> + List.iter (fun l -> + if q.q_mag >= l.l_qty.q_mag -. eps then + match l.l_kind with + | Hard -> + add Err (limit_error l.l_name) s.s_line + (Printf.sprintf + "would expose %s '%s' to %s; hard %s limit is %s" + (if m.m_is_tool then "tool" else "material") + tname (show_qty q) l.l_name (show_qty l.l_qty)) + | Soft -> + add Warn ("APPROACHING_" ^ String.uppercase_ascii l.l_name) s.s_line + (Printf.sprintf "%s '%s' at %s reaches its soft %s threshold %s" + (if m.m_is_tool then "tool" else "material") + tname (show_qty q) l.l_name (show_qty l.l_qty))) + (limits_for_dim m capb.c_quantity)) + targets in + + let check_device_range (q : quantity) = + if q.q_mag > capb.c_hi.q_mag +. eps then + add Err (over_code capb.c_quantity) s.s_line + (Printf.sprintf "%s.%s requested %s; device envelope tops out at %s" + app.a_name s.s_verb (show_qty q) (show_qty capb.c_hi)) + else if q.q_mag < capb.c_lo.q_mag -. eps then + add Err "UNDER_RANGE" s.s_line + (Printf.sprintf "%s.%s requested %s; below device minimum %s" + app.a_name s.s_verb (show_qty q) (show_qty capb.c_lo)) in + + let check_reach (q : quantity) = + match capb.c_reach with + | Some (_, rhi) when q.q_mag > rhi.q_mag +. eps -> + add Err "OVER_REACH" s.s_line + (Printf.sprintf "%s.%s requested reach %s; arm reach envelope is %s" + app.a_name s.s_verb (show_qty q) (show_qty rhi)) + | _ -> () in + + let check_slope (q : quantity) = + match capb.c_slope with + | Some (_, shi) -> + let eff = shi.q_mag *. sf in + if q.q_mag > eff +. eps then + add Err "OVER_SLOPE" s.s_line + (Printf.sprintf "%s.%s slope %s exceeds %s envelope %s (style %s)" + app.a_name s.s_verb (show_qty q) app.a_name + (show_qty { q_mag = eff; q_dim = TempRate }) style) + | None -> () in + + let primary_seen = ref false in + List.iter (fun (n, v) -> + let ln = String.lowercase_ascii n in + match v with + | AQty q -> + if ln = "reach" && dim_eq q.q_dim Length then check_reach q + else if ln = "slope" && dim_eq q.q_dim TempRate then check_slope q + else if dim_eq q.q_dim capb.c_quantity then + (primary_seen := true; check_device_range q; check_targets_exposure q) + else if List.mem ln ["target"; "temp"; "to"; "torque"; "speed"; "rate"; "power"] then + add Err "DIM_MISMATCH" s.s_line + (Printf.sprintf "argument '%s' is a %s but %s.%s controls %s" + n (dim_name q.q_dim) app.a_name s.s_verb (dim_name capb.c_quantity)) + else () (* benign auxiliary (e.g. depth: 4cm on a Poach) *) + | ATag t -> + if ln = "slope" then + (match slope_tag_rate t with Some r -> check_slope r | None -> ()) + else () + | AIdent _ -> ()) + named; + + (* Soundness backstop: if no explicit primary quantity was given but + the `until ~ metric >= X` guard drives the controlled quantity to X, + treat X as the exposure so a dangerous guard cannot slip past. *) + (match s.s_until with + | Some (TropCond (_, _, q)) + when (not !primary_seen) && dim_eq q.q_dim capb.c_quantity -> + check_device_range q; check_targets_exposure q + | _ -> ()); + + (* linearity: targets liveness + consumption *) + List.iter (fun tname -> + match Hashtbl.find_opt resources tname with + | Some rr -> + if not rr.live then + add Err "DOUBLE_USE" s.s_line + (Printf.sprintf "resource '%s' is used again after being consumed" tname) + else if capb.c_consumes then rr.live <- false + | None -> + (match Hashtbl.find_opt e.mats tname with + | Some m when m.m_is_tool -> () (* tools are non-linear *) + | Some _ -> + add Err "UNUSED_RESOURCE" s.s_line + (Printf.sprintf "material '%s' must be brought into scope with 'use' before it is acted on" tname) + | None -> + add Err "UNDECLARED_TARGET" s.s_line + (Printf.sprintf "target '%s' is neither a 'use'd resource nor a known tool" tname))) + targets; + + (* echo witness obligation *) + (match s.s_until with + | Some (EchoCond (ec, _, _)) -> + if not (Hashtbl.mem e.echoes ec) then + add Err "UNKNOWN_ECHO" s.s_line + (Printf.sprintf "step witnesses @%s but no such echo is declared" ec) + else (match proving with + | Some pe when pe = ec -> () + | _ -> + add Err "UNWITNESSED_SYNC" s.s_line + (Printf.sprintf "step completes on @%s but the enclosing sync has no matching 'proving @%s'" ec ec)) + | _ -> ())) + in + + let check_sync (sb : sync_block) = + List.iter (fun a -> + if resolve_actor e a = None then + add Err "UNKNOWN_ACTOR" sb.sy_line (Printf.sprintf "sync actor '%s' is not a declared appliance or chef" a)) + sb.sy_actors; + (match sb.sy_proving with + | Some pe when not (Hashtbl.mem e.echoes pe) -> + add Err "UNKNOWN_ECHO" sb.sy_line (Printf.sprintf "sync proves @%s but no such echo is declared" pe) + | _ -> ()); + List.iter (check_step ~proving:sb.sy_proving) sb.sy_steps in + + let check_bind (b : bind_step) = + List.iter (fun inp -> + match Hashtbl.find_opt resources inp with + | Some rr -> + if not rr.live then + add Err "DOUBLE_USE" b.b_line (Printf.sprintf "bind input '%s' was already consumed" inp) + else rr.live <- false + | None -> + add Err "UNDECLARED_TARGET" b.b_line + (Printf.sprintf "bind input '%s' is not a 'use'd resource" inp)) + b.b_inputs; + if resolve_actor e b.b_actor = None then + add Err "UNKNOWN_ACTOR" b.b_line (Printf.sprintf "bind actor '%s' is not a declared appliance or chef" b.b_actor); + (match Hashtbl.find_opt e.mats b.b_output with + | Some m -> + Hashtbl.replace resources b.b_output { live = true; r_mat = m; r_line = b.b_line; from_bind = true }; + bind_outputs := b.b_output :: !bind_outputs + | None -> + add Err "UNKNOWN_MATERIAL" b.b_line + (Printf.sprintf "dyadic bind produces '%s', which is not a known material" b.b_output)) in + + let do_stmt = function + | Use u -> + (match Hashtbl.find_opt e.mats u.u_material with + | Some m when not m.m_is_tool -> + Hashtbl.replace resources u.u_as { live = true; r_mat = m; r_line = u.u_line; from_bind = false } + | Some _ -> + add Warn "TOOL_USED" u.u_line (Printf.sprintf "'%s' is a tool and need not be brought in with 'use'" u.u_material) + | None -> + add Err "UNKNOWN_MATERIAL" u.u_line (Printf.sprintf "'use %s' names an unknown material" u.u_material)) + | Sync sb -> check_sync sb + | Bind b -> check_bind b in + + List.iter do_stmt r.r_body; + + (* leak check: a dyadic-bound product nobody consumed *) + List.iter (fun name -> + match Hashtbl.find_opt resources name with + | Some rr when rr.live -> + add Warn "LEAK" rr.r_line (Printf.sprintf "resource '%s' is produced but never consumed" name) + | _ -> ()) + !bind_outputs + in + List.iter check_recipe prog.p_recipes; + List.rev !diags diff --git a/src/compiler/dune b/src/compiler/dune new file mode 100644 index 0000000..945f83b --- /dev/null +++ b/src/compiler/dune @@ -0,0 +1,6 @@ +; SPDX-License-Identifier: MPL-2.0 +; KitchenSpeak v2.0 reference front end + physical-safety checker. +; Dependency-free: OCaml stdlib only, so `dune build` needs no opam installs. +(executable + (name main) + (flags (:standard -w +a-4-40-42-44-45-70))) diff --git a/src/compiler/dune-project b/src/compiler/dune-project new file mode 100644 index 0000000..15376b2 --- /dev/null +++ b/src/compiler/dune-project @@ -0,0 +1,2 @@ +(lang dune 3.0) +(name kitchenspeak) diff --git a/src/compiler/hal.ml b/src/compiler/hal.ml new file mode 100644 index 0000000..c71f475 --- /dev/null +++ b/src/compiler/hal.ml @@ -0,0 +1,137 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + Hardware Abstraction Layer: lower a well-typed program to the abstract + capability trace (docs/design/0003-hal-and-hardware.adoc) and project it to a + concrete backend (Matter / MQTT / ROS 2). Lowering is only ever invoked AFTER + the checker is clean, so the trust boundary holds: the type that made the + recipe safe is the type that gates which devices it may lower to. + + Dependency-free JSON: emitted by hand so the prototype needs no opam installs. +*) + +open Ast +open Check + +type target = Abstract | Matter | Mqtt | Ros2 + +let buf = Buffer.create 4096 +let out s = Buffer.add_string buf s + +let jstr s = + let b = Buffer.create (String.length s + 2) in + Buffer.add_char b '"'; + String.iter (fun c -> match c with + | '"' -> Buffer.add_string b "\\\"" + | '\\' -> Buffer.add_string b "\\\\" + | '\n' -> Buffer.add_string b "\\n" + | c -> Buffer.add_char b c) s; + Buffer.add_char b '"'; + Buffer.contents b + +let jnum f = + if Float.equal (Float.round f) f then Printf.sprintf "%.0f" f else Printf.sprintf "%g" f + +let fail_name = function Abort -> "ABORT" | Recover -> "RECOVER" | Warm -> "WARM" + +let cond_str = function + | TropCond (m, _, q) -> Printf.sprintf "~ %s -> %s" m (show_qty q) + | EchoCond (e, _, v) -> Printf.sprintf "@%s == %s" e v + +(* primary requested value in a step, matching the capability's controlled dim *) +let primary_value capb args = + List.fold_left (fun acc a -> match a with + | ANamed (_, AQty q) when dim_eq q.q_dim capb.c_quantity -> Some q + | _ -> acc) None args + +let named_qty name args = + List.fold_left (fun acc a -> match a with + | ANamed (n, AQty q) when String.lowercase_ascii n = name -> Some q + | _ -> acc) None args + +(* Matter projection table: verb -> (cluster, attribute) *) +let matter_map verb = match verb with + | "Heat" | "Bake" | "Sear" -> ("Thermostat", "OccupiedHeatingSetpoint") + | "Poach" -> ("TemperatureControl", "TemperatureSetpoint") + | "Cool" -> ("Thermostat", "OccupiedCoolingSetpoint") + | "Whisk" -> ("FanControl", "PercentSetting") + | _ -> ("Custom", "Setpoint") + +let ros2_action verb = match verb with + | "Move" | "Plate" -> "control_msgs/action/FollowJointTrajectory" + | "Whisk" -> "control_msgs/action/GripperCommand" + | _ -> "control_msgs/action/JointGroupCommand" + +let emit_step target (app : appliance) (capb : capability) (s : step) ~first = + if not first then out ",\n"; + let pv = primary_value capb s.s_args in + let value_json = match pv with + | Some q -> Printf.sprintf "{ \"quantity\": %s, \"value\": %s, \"unit\": %s }" + (jstr (dim_name capb.c_quantity)) (jnum q.q_mag) (jstr (show_qty q)) + | None -> "null" in + let reach_json = match named_qty "reach" s.s_args with + | Some q -> Printf.sprintf ", \"reach\": %s" (jnum q.q_mag) | None -> "" in + let maxd = match s.s_maxdur with Some q -> jnum q.q_mag | None -> "null" in + let onf = match s.s_onfail with Some f -> jstr (fail_name f) | None -> "null" in + let wit = match s.s_until with + | Some (EchoCond (e, _, _)) -> jstr ("@" ^ e) + | _ -> "null" in + let until = match s.s_until with Some c -> jstr (cond_str c) | None -> "null" in + out " {\n"; + out (Printf.sprintf " \"actor\": %s, \"appliance\": %s, \"verb\": %s,\n" + (jstr s.s_actor) (jstr app.a_name) (jstr s.s_verb)); + out (Printf.sprintf " \"envelope\": %s%s,\n" value_json reach_json); + out (Printf.sprintf " \"until\": %s, \"max_duration_s\": %s, \"on_fail\": %s, \"witness\": %s" + until maxd onf wit); + (match target with + | Matter -> + let cl, attr = matter_map s.s_verb in + out (Printf.sprintf ",\n \"matter\": { \"cluster\": %s, \"attribute\": %s, \"value\": %s }" + (jstr cl) (jstr attr) (match pv with Some q -> jnum q.q_mag | None -> "null")) + | Mqtt -> + out (Printf.sprintf ",\n \"mqtt\": { \"topic\": %s, \"payload\": %s }" + (jstr (Printf.sprintf "kitchenspeak/%s/%s" app.a_name s.s_verb)) + value_json) + | Ros2 -> + out (Printf.sprintf ",\n \"ros2\": { \"server\": %s, \"action\": %s }" + (jstr (Printf.sprintf "/%s/%s" (String.lowercase_ascii app.a_name) (String.lowercase_ascii s.s_verb))) + (jstr (ros2_action s.s_verb))) + | Abstract -> ()); + out "\n }" + +let lower_recipe target (e : env) (r : recipe) = + out " {\n"; + out (Printf.sprintf " \"recipe\": %s,\n" (jstr r.r_name)); + out (Printf.sprintf " \"ceremony\": %s,\n" + (match r.r_under with Some c -> jstr c | None -> "null")); + out (Printf.sprintf " \"target\": %s,\n" + (jstr (match target with Abstract -> "abstract" | Matter -> "matter" + | Mqtt -> "mqtt" | Ros2 -> "ros2"))); + out " \"steps\": [\n"; + let first = ref true in + List.iter (function + | Sync sb -> + List.iter (fun s -> + match resolve_actor e s.s_actor with + | Some (app, _) -> + (match find_cap app s.s_verb with + | Some capb -> emit_step target app capb s ~first:!first; first := false + | None -> ()) + | None -> ()) + sb.sy_steps + | _ -> ()) + r.r_body; + out "\n ]\n }" + +let lower_program target (prog : program) : string = + Buffer.clear buf; + let e = build_env prog in + out "[\n"; + let first = ref true in + List.iter (fun r -> + if not !first then out ",\n"; + first := false; + lower_recipe target e r) + prog.p_recipes; + out "\n]\n"; + Buffer.contents buf diff --git a/src/compiler/lexer.ml b/src/compiler/lexer.ml new file mode 100644 index 0000000..5cd01f1 --- /dev/null +++ b/src/compiler/lexer.ml @@ -0,0 +1,102 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + Hand-written lexer (closes grammar.ebnf open item O3). + + Maximal-munch over a UTF-8 byte string. Keywords are not distinguished from + identifiers here — the parser matches keyword spellings on TIDENT — which + keeps the token set small and the lexer total. Dimensioned literals are a + single token: a digit-led magnitude immediately followed by a unit run of + letters, '/', and UTF-8 continuation bytes (so "0.8Pa·s" and "2C/s" lex whole). +*) + +type token = + | TIDENT of string + | TQTY of float * string (* magnitude, raw unit string ("" = dimensionless) *) + | TAT | TTILDE | TBIND | TARROW | TDOTDOT | TDOT + | TCOLON | TSEMI | TCOMMA + | TLPAREN | TRPAREN | TLBRACE | TRBRACE + | TGE | TLE | TGT | TLT | TEQ + | TEOF + +exception Lex_error of string * int + +let is_alpha c = + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c = '_' +let is_digit c = c >= '0' && c <= '9' +let is_ident_char c = is_alpha c || is_digit c +(* unit characters: letters, '/', and any UTF-8 continuation/lead byte (>=0x80) *) +let is_unit_char c = is_alpha c || c = '/' || Char.code c >= 0x80 + +let tokenize (src : string) : (token * int) array = + let n = String.length src in + let i = ref 0 in + let line = ref 1 in + let out = ref [] in + let emit t = out := (t, !line) :: !out in + let peek k = if !i + k < n then src.[!i + k] else '\000' in + while !i < n do + let c = src.[!i] in + if c = '\n' then (incr line; incr i) + else if c = ' ' || c = '\t' || c = '\r' then incr i + else if c = '/' && peek 1 = '/' then begin + (* line comment *) + while !i < n && src.[!i] <> '\n' do incr i done + end + else if c = '/' && peek 1 = '*' then begin + (* block comment *) + i := !i + 2; + let closed = ref false in + while !i < n && not !closed do + if src.[!i] = '\n' then incr line; + if src.[!i] = '*' && peek 1 = '/' then (i := !i + 2; closed := true) + else incr i + done; + if not !closed then raise (Lex_error ("unterminated block comment", !line)) + end + else if is_digit c then begin + (* dimensioned literal: number then optional unit run *) + let start = !i in + while !i < n && is_digit src.[!i] do incr i done; + if !i < n && src.[!i] = '.' && !i + 1 < n && is_digit src.[!i + 1] then begin + incr i; + while !i < n && is_digit src.[!i] do incr i done + end; + let num = float_of_string (String.sub src start (!i - start)) in + let us = !i in + while !i < n && is_unit_char src.[!i] do incr i done; + let unit = String.sub src us (!i - us) in + emit (TQTY (num, unit)) + end + else if is_alpha c then begin + let start = !i in + while !i < n && is_ident_char src.[!i] do incr i done; + emit (TIDENT (String.sub src start (!i - start))) + end + else begin + (match c with + | '@' -> emit TAT; incr i + | '~' -> emit TTILDE; incr i + | '(' -> emit TLPAREN; incr i + | ')' -> emit TRPAREN; incr i + | '{' -> emit TLBRACE; incr i + | '}' -> emit TRBRACE; incr i + | ':' -> emit TCOLON; incr i + | ';' -> emit TSEMI; incr i + | ',' -> emit TCOMMA; incr i + | '.' -> if peek 1 = '.' then (emit TDOTDOT; i := !i + 2) + else (emit TDOT; incr i) + | '<' -> if peek 1 = '~' && peek 2 = '>' then (emit TBIND; i := !i + 3) + else if peek 1 = '=' then (emit TLE; i := !i + 2) + else (emit TLT; incr i) + | '>' -> if peek 1 = '=' then (emit TGE; i := !i + 2) + else (emit TGT; incr i) + | '=' -> if peek 1 = '=' then (emit TEQ; i := !i + 2) + else (emit TEQ; incr i) + | '-' -> if peek 1 = '>' then (emit TARROW; i := !i + 2) + else raise (Lex_error ("unexpected '-'", !line)) + | _ -> raise (Lex_error (Printf.sprintf "unexpected character %C" c, !line))) + end + done; + emit TEOF; + Array.of_list (List.rev !out) diff --git a/src/compiler/main.ml b/src/compiler/main.ml new file mode 100644 index 0000000..2429a10 --- /dev/null +++ b/src/compiler/main.ml @@ -0,0 +1,91 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + ksc — the KitchenSpeak v2.0 reference CLI. + + ksc check FILE type-check + physical-safety check + ksc lower [--target T] FILE lower a clean recipe to the HAL (T = abstract|matter|mqtt|ros2) + ksc parse FILE parse only (smoke test) + + Exit codes: 0 = OK, 1 = errors found, 2 = usage error. +*) + +let read_file p = + let ic = open_in_bin p in + let n = in_channel_length ic in + let s = really_input_string ic n in + close_in ic; s + +let print_diag file (d : Check.diag) = + let tag = match d.d_sev with Check.Err -> "error" | Check.Warn -> "warning" in + Printf.printf "%s:%d: %s [%s] %s\n" file d.d_line tag d.d_code d.d_msg + +let frontend file = + let src = read_file file in + let toks = Lexer.tokenize src in + Parser.parse toks + +let do_check file = + try + let prog = frontend file in + let diags = Check.check prog in + List.iter (print_diag file) diags; + let errs = List.filter (fun (d : Check.diag) -> d.d_sev = Check.Err) diags in + let warns = List.filter (fun (d : Check.diag) -> d.d_sev = Check.Warn) diags in + if errs = [] then begin + Printf.printf "OK: %s — %d recipe(s) type-check, %d warning(s).\n" + file (List.length prog.p_recipes) (List.length warns); + exit 0 + end else begin + Printf.printf "FAIL: %s — %d error(s), %d warning(s).\n" + file (List.length errs) (List.length warns); + exit 1 + end + with + | Lexer.Lex_error (m, l) -> Printf.printf "%s:%d: error [LEX] %s\n" file l m; exit 1 + | Parser.Parse_error (m, l) -> Printf.printf "%s:%d: error [PARSE] %s\n" file l m; exit 1 + +let do_lower targ file = + let target = match targ with + | "matter" -> Hal.Matter | "mqtt" -> Hal.Mqtt | "ros2" -> Hal.Ros2 | _ -> Hal.Abstract in + try + let prog = frontend file in + let diags = Check.check prog in + let errs = List.filter (fun (d : Check.diag) -> d.d_sev = Check.Err) diags in + if errs <> [] then begin + List.iter (print_diag file) errs; + Printf.eprintf "refusing to lower %s: %d safety error(s) — the trust boundary holds.\n" + file (List.length errs); + exit 1 + end else + print_string (Hal.lower_program target prog) + with + | Lexer.Lex_error (m, l) -> Printf.printf "%s:%d: error [LEX] %s\n" file l m; exit 1 + | Parser.Parse_error (m, l) -> Printf.printf "%s:%d: error [PARSE] %s\n" file l m; exit 1 + +let do_parse file = + try + let prog = frontend file in + Printf.printf "parsed %s: %d declaration(s), %d recipe(s).\n" + file (List.length prog.p_decls) (List.length prog.p_recipes); + exit 0 + with + | Lexer.Lex_error (m, l) -> Printf.printf "%s:%d: error [LEX] %s\n" file l m; exit 1 + | Parser.Parse_error (m, l) -> Printf.printf "%s:%d: error [PARSE] %s\n" file l m; exit 1 + +let usage () = + prerr_string + "ksc — KitchenSpeak v2.0 reference compiler\n\ + usage:\n\ + \ ksc check FILE\n\ + \ ksc lower [--target abstract|matter|mqtt|ros2] FILE\n\ + \ ksc parse FILE\n"; + exit 2 + +let () = + match Array.to_list Sys.argv with + | _ :: "check" :: file :: _ -> do_check file + | _ :: "lower" :: "--target" :: t :: file :: _ -> do_lower t file + | _ :: "lower" :: file :: _ -> do_lower "abstract" file + | _ :: "parse" :: file :: _ -> do_parse file + | _ -> usage () diff --git a/src/compiler/parser.ml b/src/compiler/parser.ml new file mode 100644 index 0000000..cd55e0b --- /dev/null +++ b/src/compiler/parser.ml @@ -0,0 +1,318 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + Recursive-descent parser for KitchenSpeak v2.0. + + Follows the concrete grammar in docs/spec/0002-concrete-grammar.adoc. Keywords + are recognised as identifier-shaped tokens and matched by spelling, so the + token set stays small. Step clauses (until / max_duration / on_fail) are + accepted in any order and their *presence* is enforced later by the checker, + which yields precise diagnostics (MISSING_ONFAIL, …) rather than parse noise. +*) + +open Ast +open Lexer + +exception Parse_error of string * int + +let dim_of_name s = + match String.lowercase_ascii s with + | "temperature" | "temp" -> Temperature + | "time" -> Time + | "mass" -> Mass + | "torque" -> Torque + | "angularrate" | "rate" | "speed" | "rpm" -> AngularRate + | "length" | "reach" | "depth" -> Length + | "power" -> Power + | "viscosity" -> Viscosity + | "temprate" -> TempRate + | _ -> Dimensionless + +let parse (arr : (token * int) array) : program = + let p = ref 0 in + let peek () = fst arr.(!p) in + let line () = snd arr.(!p) in + let adv () = incr p in + let err msg = raise (Parse_error (msg, line ())) in + let next () = let t = peek () in adv (); t in + let expect t = if peek () = t then adv () else err "unexpected token" in + let ident () = match peek () with + | TIDENT s -> adv (); s + | _ -> err "expected identifier" in + let is_kw s = match peek () with TIDENT k -> k = s | _ -> false in + let kw s = if is_kw s then adv () else err ("expected keyword '" ^ s ^ "'") in + let accept t = if peek () = t then (adv (); true) else false in + let parse_qty () = match peek () with + | TQTY (m, u) -> adv (); Units.normalize m u + | _ -> err "expected a dimensioned quantity" in + + (* ── limit blocks: thermal/mechanical/chemical { (hard|soft)? name: qty ; } ── *) + let parse_limits () = + expect TLBRACE; + let ls = ref [] in + let rec lp () = match peek () with + | TRBRACE -> () + | TIDENT k -> + let kind = match k with + | "hard" -> adv (); Hard + | "soft" -> adv (); Soft + | _ -> Hard in + let lname = ident () in + expect TCOLON; + let q = parse_qty () in + ignore (accept TSEMI); + ls := { l_kind = kind; l_name = lname; l_qty = q } :: !ls; + lp () + | _ -> err "expected a limit name or '}'" in + lp (); + expect TRBRACE; + List.rev !ls in + + let empty_env = { e_thermal = []; e_mech = []; e_chem = [] } in + + let parse_envelope_into env = + (* peek already at thermal/mechanical/chemical *) + match peek () with + | TIDENT "thermal" -> adv (); { env with e_thermal = env.e_thermal @ parse_limits () } + | TIDENT "mechanical" -> adv (); { env with e_mech = env.e_mech @ parse_limits () } + | TIDENT "chemical" -> adv (); { env with e_chem = env.e_chem @ parse_limits () } + | _ -> err "expected thermal/mechanical/chemical" in + + let parse_material () = + let name = ident () in + expect TLBRACE; + let phase = ref "UNSPECIFIED" in + let env = ref empty_env in + let rec body () = match peek () with + | TRBRACE -> () + | TIDENT "phase" -> adv (); expect TCOLON; phase := ident (); ignore (accept TSEMI); body () + | TIDENT ("thermal" | "mechanical" | "chemical") -> env := parse_envelope_into !env; body () + | _ -> err "expected phase/thermal/mechanical/chemical or '}'" in + body (); + expect TRBRACE; + { m_name = name; m_phase = !phase; m_env = !env; m_is_tool = false; m_tool_material = None } in + + let parse_tool () = + let name = ident () in + expect TLBRACE; + let tmat = ref None in + let env = ref empty_env in + let rec body () = match peek () with + | TRBRACE -> () + | TIDENT "material" -> adv (); expect TCOLON; tmat := Some (ident ()); ignore (accept TSEMI); body () + | TIDENT ("thermal" | "mechanical" | "chemical") -> env := parse_envelope_into !env; body () + | _ -> err "expected material/thermal/mechanical/chemical or '}'" in + body (); + expect TRBRACE; + { m_name = name; m_phase = "SOLID"; m_env = !env; m_is_tool = true; m_tool_material = !tmat } in + + let parse_range () = + let a = parse_qty () in expect TDOTDOT; let b = parse_qty () in (a, b) in + + let parse_capability () = + let verb = ident () in + expect TLBRACE; + let quantity = ref Dimensionless in + let rng = ref None and slope = ref None and reach = ref None in + let consumes = ref false and mods = ref [] in + let rec body () = match peek () with + | TRBRACE -> () + | TIDENT "quantity" -> adv (); expect TCOLON; quantity := dim_of_name (ident ()); ignore (accept TSEMI); body () + | TIDENT "range" -> adv (); expect TCOLON; rng := Some (parse_range ()); ignore (accept TSEMI); body () + | TIDENT "slope" -> adv (); expect TCOLON; slope := Some (parse_range ()); ignore (accept TSEMI); body () + | TIDENT "reach" -> adv (); expect TCOLON; reach := Some (parse_range ()); ignore (accept TSEMI); body () + | TIDENT "consumes" -> adv (); expect TCOLON; consumes := (ident () = "true"); ignore (accept TSEMI); body () + | TIDENT "modalities" -> adv (); expect TCOLON; + let rec ml () = let m = ident () in mods := m :: !mods; if accept TCOMMA then ml () in + ml (); ignore (accept TSEMI); body () + | _ -> err "expected capability field or '}'" in + body (); + expect TRBRACE; + let lo, hi = match !rng with + | Some (a, b) -> a, b + | None -> { q_mag = 0.0; q_dim = !quantity }, { q_mag = 1.0e12; q_dim = !quantity } in + { c_verb = verb; c_quantity = !quantity; c_lo = lo; c_hi = hi; + c_slope = !slope; c_reach = !reach; c_consumes = !consumes; c_modalities = List.rev !mods } in + + let parse_appliance () = + let name = ident () in + expect TLBRACE; + let caps = ref [] in + let rec body () = match peek () with + | TRBRACE -> () + | TIDENT "capability" -> adv (); caps := parse_capability () :: !caps; body () + | _ -> err "expected 'capability' or '}'" in + body (); + expect TRBRACE; + { a_name = name; a_caps = List.rev !caps } in + + let parse_chef () = + let name = ident () in + kw "controls"; + let appl = ident () in + let style = if is_kw "style" then (adv (); ident ()) else "BALANCED" in + expect TSEMI; + { ch_name = name; ch_controls = appl; ch_style = style } in + + let parse_echo () = + expect TAT; + let name = ident () in + kw "on"; + let appl = ident () in + expect TDOT; + let modn = ident () in + expect TSEMI; + { ec_name = name; ec_appliance = appl; ec_modality = modn } in + + let parse_ceremony () = + let name = ident () in + expect TLBRACE; + let settings = ref [] in + let rec body () = match peek () with + | TRBRACE -> () + | TIDENT k -> adv (); expect TCOLON; + let v = match peek () with + | TIDENT s -> adv (); s + | TQTY (m, u) -> adv (); Ast.show_qty (Units.normalize m u) + | _ -> err "expected ceremony setting value" in + ignore (accept TSEMI); ignore (accept TCOMMA); + settings := (k, v) :: !settings; body () + | _ -> err "expected ceremony setting or '}'" in + body (); + expect TRBRACE; + { ce_name = name; ce_settings = List.rev !settings } in + + let parse_decl () = match next () with + | TIDENT "material" -> DMaterial (parse_material ()) + | TIDENT "tool" -> DTool (parse_tool ()) + | TIDENT "appliance" -> DAppliance (parse_appliance ()) + | TIDENT "chef" -> DChef (parse_chef ()) + | TIDENT "echo" -> DEcho (parse_echo ()) + | TIDENT "ceremony" -> DCeremony (parse_ceremony ()) + | _ -> err "expected a declaration head" in + + (* ── steps ───────────────────────────────────────────────────────────────── *) + let parse_cmp () = match next () with + | TGE -> Ge | TLE -> Le | TGT -> Gt | TLT -> Lt | TEQ -> Eq + | _ -> err "expected a comparison operator" in + + let parse_cond () = match peek () with + | TTILDE -> adv (); + let metric = ident () in let c = parse_cmp () in let q = parse_qty () in + TropCond (metric, c, q) + | TAT -> adv (); + let e = ident () in let c = parse_cmp () in + let v = match peek () with + | TIDENT s -> adv (); s + | TQTY (m, u) -> adv (); Ast.show_qty (Units.normalize m u) + | _ -> err "expected echo comparison value" in + EchoCond (e, c, v) + | _ -> err "expected '~' or '@' condition" in + + let parse_fail () = match next () with + | TIDENT "ABORT" -> Abort | TIDENT "RECOVER" -> Recover | TIDENT "WARM" -> Warm + | _ -> err "expected ABORT/RECOVER/WARM" in + + let parse_argval () = match peek () with + | TQTY (m, u) -> adv (); AQty (Units.normalize m u) + | TIDENT s -> adv (); if String.uppercase_ascii s = s then ATag s else AIdent s + | _ -> err "expected argument value" in + + let parse_arg () = match peek () with + | TIDENT name -> adv (); + if accept TCOLON then ANamed (name, parse_argval ()) else APos name + | _ -> err "expected an argument" in + + let parse_args () = + if peek () = TRPAREN then [] + else + let rec go acc = + let a = parse_arg () in + let acc = a :: acc in + if accept TCOMMA then go acc else List.rev acc in + go [] in + + let parse_step () = + let ln = line () in + let actor = ident () in + expect TDOT; + let verb = ident () in + expect TLPAREN; + let args = parse_args () in + expect TRPAREN; + let untilc = ref None and maxd = ref None and onf = ref None in + let rec clauses () = match peek () with + | TIDENT "until" -> adv (); untilc := Some (parse_cond ()); clauses () + | TIDENT "max_duration" -> adv (); maxd := Some (parse_qty ()); clauses () + | TIDENT "on_fail" -> adv (); onf := Some (parse_fail ()); clauses () + | TSEMI -> adv () + | _ -> err "expected until / max_duration / on_fail / ';'" in + clauses (); + { s_actor = actor; s_verb = verb; s_args = args; + s_until = !untilc; s_maxdur = !maxd; s_onfail = !onf; s_line = ln } in + + let parse_sync () = + let ln = line () in + kw "sync"; + expect TLPAREN; + let actors = ref [] in + let rec al () = actors := ident () :: !actors; if accept TCOMMA then al () in + al (); + expect TRPAREN; + let proving = if is_kw "proving" then (adv (); expect TAT; Some (ident ())) else None in + expect TLBRACE; + let steps = ref [] in + let rec sl () = match peek () with + | TRBRACE -> () + | _ -> steps := parse_step () :: !steps; sl () in + sl (); + expect TRBRACE; + { sy_actors = List.rev !actors; sy_proving = proving; sy_steps = List.rev !steps; sy_line = ln } in + + let parse_bind () = + let ln = line () in + expect TLPAREN; + let inputs = ref [] in + let rec il () = inputs := ident () :: !inputs; if accept TCOMMA then il () in + il (); + expect TRPAREN; + expect TBIND; + let actor = ident () in + expect TARROW; + let out = ident () in + ignore (accept TSEMI); + { b_inputs = List.rev !inputs; b_actor = actor; b_output = out; b_line = ln } in + + let parse_rstmt () = match peek () with + | TIDENT "use" -> adv (); + let ln = line () in + let m = ident () in + let asn = if is_kw "as" then (adv (); ident ()) else m in + ignore (accept TSEMI); + Use { u_material = m; u_as = asn; u_line = ln } + | TIDENT "sync" -> Sync (parse_sync ()) + | TLPAREN -> Bind (parse_bind ()) + | _ -> err "expected use / sync / dyadic-bind" in + + let parse_recipe () = + kw "recipe"; + let name = ident () in + let under = if is_kw "under" then (adv (); Some (ident ())) else None in + expect TLBRACE; + let body = ref [] in + let rec bl () = match peek () with + | TRBRACE -> () + | _ -> body := parse_rstmt () :: !body; bl () in + bl (); + expect TRBRACE; + { r_name = name; r_under = under; r_body = List.rev !body } in + + let decls = ref [] and recipes = ref [] in + let rec top () = match peek () with + | TEOF -> () + | TIDENT ("material" | "tool" | "appliance" | "chef" | "echo" | "ceremony") -> + decls := parse_decl () :: !decls; top () + | TIDENT "recipe" -> recipes := parse_recipe () :: !recipes; top () + | _ -> err "expected a declaration or 'recipe'" in + top (); + { p_decls = List.rev !decls; p_recipes = List.rev !recipes } diff --git a/src/compiler/stdlib_ks.ml b/src/compiler/stdlib_ks.ml new file mode 100644 index 0000000..152b27f --- /dev/null +++ b/src/compiler/stdlib_ks.ml @@ -0,0 +1,99 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + The KitchenSpeak standard ontology: built-in materials, tools, and appliances + with REAL physical safety envelopes. Recipes may `use` these without + redeclaring them; a file-level declaration of the same name overrides the + built-in. The narrative version (with sources and soft/hard rationale) is + docs/design/0004-stdlib-ontology.adoc. + + Convention used by the checker (see check.ml): only UPPER thermal limits are + hazards in the heating direction. `soft` limits warn (quality); `hard` limits + reject (ruin / safety). Numbers are conservative engineering defaults, + overridable per recipe. +*) + +open Ast + +let degC m = { q_mag = m; q_dim = Temperature } +let nm m = { q_mag = m; q_dim = Torque } +let rpm m = { q_mag = m; q_dim = AngularRate } +let cm m = { q_mag = m; q_dim = Length } +let cps m = { q_mag = m; q_dim = TempRate } + +let hard n v = { l_kind = Hard; l_name = n; l_qty = v } +let soft n v = { l_kind = Soft; l_name = n; l_qty = v } + +let mat name phase th = + { m_name = name; m_phase = phase; + m_env = { e_thermal = th; e_mech = []; e_chem = [] }; + m_is_tool = false; m_tool_material = None } + +let tool name tm th = + { m_name = name; m_phase = "SOLID"; + m_env = { e_thermal = th; e_mech = []; e_chem = [] }; + m_is_tool = true; m_tool_material = Some tm } + +let cap ?(slope = None) ?(reach = None) ?(consumes = false) ?(mods = []) verb qd lo hi = + { c_verb = verb; c_quantity = qd; c_lo = lo; c_hi = hi; + c_slope = slope; c_reach = reach; c_consumes = consumes; c_modalities = mods } + +(* ── Materials ────────────────────────────────────────────────────────────── *) +let materials : material list = [ + (* Milk: scalds at ~70C (quality), curdles/denatures hard at ~82C. *) + mat "Milk" "LIQUID" [ soft "scald" (degC 70.); hard "curdle" (degC 82.) ]; + (* Egg: white coagulates ~63C (desired, so soft), scorches if seared dry. *) + mat "Egg" "RAW" [ soft "coagulate" (degC 63.); hard "scorch" (degC 200.) ]; + mat "Water" "LIQUID" [ soft "boil" (degC 100.) ]; + mat "Vinegar" "LIQUID" []; + (* Chocolate: tempering band tops out ~32C (quality), scorches/seizes hot. *) + mat "Chocolate" "SOLID" [ soft "temper" (degC 32.); hard "scorch" (degC 55.) ]; + mat "Butter" "SOLID" [ soft "brown" (degC 150.); hard "burn" (degC 175.) ]; + mat "Oil" "LIQUID" [ soft "smoke" (degC 190.); hard "scorch" (degC 230.) ]; + mat "Cream" "LIQUID" [ soft "split" (degC 80.); hard "scorch" (degC 95.) ]; + mat "Steak" "RAW" [ soft "maillard" (degC 140.); hard "scorch" (degC 260.) ]; + mat "Flour" "RAW_POWDER" []; + mat "Sugar" "SOLID" [ soft "caramel" (degC 160.); hard "burn" (degC 190.) ]; + mat "Dough" "KNEADABLE" []; +] + +(* ── Tools (inert implements with material limits) ────────────────────────── *) +let tools : material list = [ + tool "Spatula" "SILICONE" [ hard "melt" (degC 260.); hard "char" (degC 320.) ]; + tool "WoodenSpoon" "WOOD" [ hard "char" (degC 250.) ]; + tool "NylonSpatula" "NYLON" [ hard "melt" (degC 190.) ]; + tool "SteelWhisk" "STAINLESS" [ hard "anneal" (degC 800.) ]; +] + +(* ── Appliances (capability envelopes) ────────────────────────────────────── *) +let appliances : appliance list = [ + { a_name = "Hob"; a_caps = [ + cap "Heat" Temperature (degC 0.) (degC 250.) + ~slope:(Some (cps 0., cps 5.)) ~mods:["visual"; "acoustic"]; + cap "Poach" Temperature (degC 0.) (degC 100.) + ~consumes:true ~mods:["visual"]; + cap "Sear" Temperature (degC 0.) (degC 250.) + ~slope:(Some (cps 0., cps 8.)) ~mods:["visual"; "acoustic"]; + ] }; + { a_name = "Oven"; a_caps = [ + cap "Bake" Temperature (degC 50.) (degC 300.) ~mods:["visual"]; + ] }; + { a_name = "SousVide"; a_caps = [ + cap "Heat" Temperature (degC 0.) (degC 95.) + ~slope:(Some (cps 0., cps 0.5)) ~mods:["thermal"]; + ] }; + { a_name = "Blender"; a_caps = [ + cap "Whisk" AngularRate (rpm 0.) (rpm 1200.) ~mods:["acoustic"]; + ] }; + { a_name = "Fridge"; a_caps = [ + cap "Cool" Temperature (degC (-20.)) (degC 8.) ~mods:["thermal"]; + ] }; + (* ROS 2 consumer-robotics arm. *) + { a_name = "Arm"; a_caps = [ + cap "Move" Torque (nm 0.) (nm 8.) + ~reach:(Some (cm 0., cm 85.)) ~mods:["visual"]; + cap "Whisk" AngularRate (rpm 0.) (rpm 1500.); + cap "Plate" Torque (nm 0.) (nm 5.) + ~reach:(Some (cm 0., cm 85.)) ~consumes:true ~mods:["visual"]; + ] }; +] diff --git a/src/compiler/units.ml b/src/compiler/units.ml new file mode 100644 index 0000000..605a6a4 --- /dev/null +++ b/src/compiler/units.ml @@ -0,0 +1,47 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(* + Units of measure (the Primitive type). + + Maps a dimensioned literal's unit suffix to its SI dimension and normalises + the magnitude to a canonical base unit, so the checker can compare two + quantities of the same dimension by raw magnitude. Temperature conversions + are affine (F/K), normalised to degrees Celsius. + + The full suffix table is the normative one in docs/spec/0001-lexical-grammar.adoc. +*) + +open Ast + +let normalize (mag : float) (unit : string) : quantity = + match unit with + | "" -> { q_mag = mag; q_dim = Dimensionless } + (* temperature, base = Celsius (affine for F/K) *) + | "C" | "degC" -> { q_mag = mag; q_dim = Temperature } + | "K" -> { q_mag = mag -. 273.15; q_dim = Temperature } + | "F" | "degF" -> { q_mag = (mag -. 32.0) *. 5.0 /. 9.0; q_dim = Temperature } + (* time, base = seconds *) + | "s" | "sec" -> { q_mag = mag; q_dim = Time } + | "min" -> { q_mag = mag *. 60.0; q_dim = Time } + | "h" | "hr" -> { q_mag = mag *. 3600.0; q_dim = Time } + (* mass, base = grams *) + | "g" -> { q_mag = mag; q_dim = Mass } + | "kg" -> { q_mag = mag *. 1000.0; q_dim = Mass } + | "mg" -> { q_mag = mag /. 1000.0; q_dim = Mass } + (* torque, base = Nm *) + | "Nm" -> { q_mag = mag; q_dim = Torque } + (* angular rate, base = rpm *) + | "rpm" -> { q_mag = mag; q_dim = AngularRate } + (* length, base = cm *) + | "cm" -> { q_mag = mag; q_dim = Length } + | "mm" -> { q_mag = mag /. 10.0; q_dim = Length } + | "m" -> { q_mag = mag *. 100.0; q_dim = Length } + (* power, base = W *) + | "W" -> { q_mag = mag; q_dim = Power } + | "kW" -> { q_mag = mag *. 1000.0; q_dim = Power } + (* viscosity, base = Pa.s ; accept several spellings incl. the UTF-8 middot *) + | "Pa\xc2\xb7s" | "Pa.s" | "Pas" -> { q_mag = mag; q_dim = Viscosity } + (* thermal slope, base = C/s *) + | "C/s" -> { q_mag = mag; q_dim = TempRate } + | "C/min" -> { q_mag = mag /. 60.0; q_dim = TempRate } + | u -> { q_mag = mag; q_dim = DUnknown u } diff --git a/tests/run-corpus.sh b/tests/run-corpus.sh new file mode 100755 index 0000000..95c3c27 --- /dev/null +++ b/tests/run-corpus.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# +# run-corpus.sh — acceptance test for the KitchenSpeak v2.0 reference checker. +# +# Builds the OCaml compiler, then asserts: +# * every recipe in examples/v2/*.ks type-checks (exit 0); +# * every recipe in examples/v2/negative/*.ks is REJECTED (exit 1) AND the +# error code named in its `// EXPECT: CODE` header appears in the output. +# +# Exit 0 iff the whole corpus behaves as specified. + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPILER="$ROOT/src/compiler" +KSC="$COMPILER/_build/default/main.exe" + +echo "== building ksc ==" +( cd "$COMPILER" && dune build 2>&1 ) || { echo "BUILD FAILED"; exit 2; } + +pass=0; fail=0 + +echo +echo "== positive corpus (must type-check) ==" +for f in "$ROOT"/examples/v2/*.ks; do + [ -e "$f" ] || continue + out="$("$KSC" check "$f" 2>&1)"; code=$? + if [ "$code" -eq 0 ]; then + echo " PASS $(basename "$f")"; pass=$((pass+1)) + else + echo " FAIL $(basename "$f") (expected accept, got exit $code)"; echo "$out" | sed 's/^/ /'; fail=$((fail+1)) + fi +done + +echo +echo "== negative corpus (must be rejected with the expected code) ==" +for f in "$ROOT"/examples/v2/negative/*.ks; do + [ -e "$f" ] || continue + want="$(grep -oE 'EXPECT:[[:space:]]*[A-Z_]+' "$f" | head -1 | sed -E 's/EXPECT:[[:space:]]*//')" + out="$("$KSC" check "$f" 2>&1)"; code=$? + if [ "$code" -ne 0 ] && echo "$out" | grep -q "\[$want\]"; then + echo " PASS $(basename "$f") -> $want"; pass=$((pass+1)) + else + echo " FAIL $(basename "$f") (wanted reject with [$want], got exit $code)"; echo "$out" | sed 's/^/ /'; fail=$((fail+1)) + fi +done + +echo +echo "== summary: $pass passed, $fail failed ==" +[ "$fail" -eq 0 ]