Skip to content

Commit abf86e8

Browse files
feat(tea): INT-07 host-side TEA runtime + run loop (Refs #182) (#258)
`lib/tea_bridge.ml` defines the TEA runtime ABI (exports affinescript_init / affinescript_update(msg:Linear) / getters / memory; affinescript.tea_layout + affinescript.ownership custom sections) but the `affinescript-tea` satellite never existed (the "first slice scaffolded" claim was aspirational, like dom.as). New `affinescript-tea/` satellite: - `parseTeaLayout()` — decodes the `affinescript.tea_layout` section (binary fmt matched to Tea_bridge.build_tea_layout_section), so the runtime is GENERIC over the model, not hard-coded to the demo TitleModel. - `TeaApp` — load/init/dispatch/model/setScreen/run. Reuses the INT-02 host-agnostic loader for Deno/Node/browser parity + the ownership accessor. `model()` is layout-driven. - `run({messages, view})` — generic run loop over any (async) iterable of i32 msgs; `view(model)` after init and each dispatch. - Linear-msg invariant enforced host-side: `affinescript_update` called exactly once per dispatch, non-re-entrant (a synchronous host-callback re-entry throws). Tests: `affinescript-tea/mod_test.js` — 9 Deno tests driving the canonical `affinescript tea-bridge` module (layout, ownership=Linear msg, init/update/setScreen, run loop) + a hand-built importing fixture that genuinely exercises the re-entrancy guard. All green. Gate: `dune test --force` 278/278 (no compiler change; zero regression). INT-01 dep cleared (#253 merged). Router/nav runtime is the separate INT-09 (`lib/tea_router.ml`), out of scope. Refs #182. Not Closes — owner closes per ISSUE-CLOSURE.
1 parent a6e8886 commit abf86e8

6 files changed

Lines changed: 491 additions & 5 deletions

File tree

affinescript-tea/README.adoc

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
= affinescript-tea
4+
:toc: macro
5+
:icons: font
6+
7+
The host-side *TEA (The Elm Architecture)* runtime + run loop for
8+
AffineScript modules. INT-07 (issue #182).
9+
10+
toc::[]
11+
12+
== What this is
13+
14+
The compiler-internal `lib/tea_bridge.ml` defines a TEA runtime ABI a
15+
conforming WASM module exposes: `affinescript_init()`,
16+
`affinescript_update(msg: i32)` (with `msg` *Linear* — consumed exactly
17+
once per update cycle), optional `affinescript_get_*` getters /
18+
`affinescript_set_screen`, an exported `memory`, and two custom
19+
sections — `affinescript.tea_layout` (model field layout) and
20+
`affinescript.ownership` (the per-function ownership kinds, including
21+
the Linear `msg` proof).
22+
23+
This package is the *generic* host runtime for any such module. It
24+
discovers the model from `affinescript.tea_layout` (it does **not**
25+
hard-code the bridge's demo `TitleModel`) and reuses the INT-02
26+
host-agnostic loader (`packages/affine-js/loader.js`) for Deno / Node /
27+
browser parity.
28+
29+
== Usage
30+
31+
[source,javascript]
32+
----
33+
import { TeaApp } from "@hyperpolymath/affinescript-tea";
34+
35+
const app = await TeaApp.load("./app.wasm");
36+
37+
// Low-level: drive cycles yourself.
38+
app.init(); // -> initial model object
39+
app.dispatch(0); // one TEA update cycle
40+
app.model(); // current model (layout-driven)
41+
42+
// Managed run loop: `messages` is any (async) iterable of i32 msgs
43+
// (DOM events, a channel, a timer, a test array — all adapt).
44+
await app.run({
45+
messages: eventStream,
46+
view: (model) => render(model),
47+
});
48+
----
49+
50+
== Linear-msg invariant
51+
52+
`affinescript_update`'s `msg` is annotated `Linear` in
53+
`affinescript.ownership` — a message is consumed exactly once per
54+
update cycle. `dispatch()` enforces this host-side: `affinescript_update`
55+
is invoked exactly once, and the call is non-re-entrant — a message
56+
source or effect that tries to dispatch again *before the in-flight
57+
cycle completes* throws (that would consume a second message inside one
58+
cycle). `app.ownership` exposes the annotations.
59+
60+
== Status
61+
62+
INT-07 first general runtime: `TeaApp` (`load`/`init`/`dispatch`/
63+
`model`/`setScreen`/`run`) + `parseTeaLayout`. Verified against the
64+
canonical bridge (`affinescript tea-bridge`) and a hand-built
65+
re-entrancy fixture — see `mod_test.js` (9 tests). The router/navigation
66+
runtime is a separate satellite (INT-09, `lib/tea_router.ml`).

affinescript-tea/deno.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@hyperpolymath/affinescript-tea",
3+
"version": "0.1.0",
4+
"exports": {
5+
".": "./mod.js"
6+
},
7+
"license": "PMPL-1.0-or-later",
8+
"tasks": {
9+
"test": "deno test --allow-read --allow-write --allow-run mod_test.js"
10+
}
11+
}

affinescript-tea/mod.js

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// affinescript-tea: the host-side TEA (The Elm Architecture) runtime + run
5+
// loop for AffineScript modules (INT-07, issue #182).
6+
//
7+
// The compiler-internal `lib/tea_bridge.ml` defines the TEA runtime ABI a
8+
// conforming WASM module exposes:
9+
//
10+
// exports:
11+
// affinescript_init() -> () write the initial model
12+
// affinescript_update(msg: i32) -> () msg is LINEAR (consumed
13+
// exactly once per cycle)
14+
// affinescript_get_<field>() -> i32 (optional getters)
15+
// affinescript_set_screen(w, h) -> () (optional)
16+
// memory
17+
// custom sections:
18+
// affinescript.tea_layout model field layout (parsed here, generically)
19+
// affinescript.ownership per-fn ownership kinds (the Linear-msg proof)
20+
//
21+
// This runtime is GENERIC: it discovers the model from `tea_layout` rather
22+
// than hard-coding any one model (the bridge's TitleModel was only a demo).
23+
// It reuses the INT-02 host-agnostic loader for Deno/Node/browser parity.
24+
25+
import {
26+
parseOwnershipSection,
27+
readBytes,
28+
} from "../packages/affine-js/loader.js";
29+
30+
/**
31+
* One model field, decoded from the `affinescript.tea_layout` section.
32+
* @typedef {Object} TeaField
33+
* @property {string} name
34+
* @property {number} offset byte offset relative to the model base
35+
* @property {"i32"} type
36+
*/
37+
38+
/**
39+
* @typedef {Object} TeaLayout
40+
* @property {number} version
41+
* @property {number} baseAddr
42+
* @property {TeaField[]} fields
43+
*/
44+
45+
/**
46+
* Parse the `affinescript.tea_layout` custom section.
47+
*
48+
* Binary format (must match `Tea_bridge.build_tea_layout_section`):
49+
* u8 version
50+
* u8 base_addr
51+
* u8 field_count
52+
* per field: u8 name_len, name_bytes, u8 offset, u8 type_tag (0x49='i32')
53+
*
54+
* @param {WebAssembly.Module} wasmModule
55+
* @returns {TeaLayout | null} null when the section is absent
56+
*/
57+
export function parseTeaLayout(wasmModule) {
58+
const secs = WebAssembly.Module.customSections(
59+
wasmModule,
60+
"affinescript.tea_layout",
61+
);
62+
if (secs.length === 0) return null;
63+
const b = new Uint8Array(secs[0]);
64+
let p = 0;
65+
const version = b[p++];
66+
const baseAddr = b[p++];
67+
const count = b[p++];
68+
const dec = new TextDecoder("utf-8");
69+
/** @type {TeaField[]} */
70+
const fields = [];
71+
for (let i = 0; i < count; i++) {
72+
const nameLen = b[p++];
73+
const name = dec.decode(b.subarray(p, p + nameLen));
74+
p += nameLen;
75+
const offset = b[p++];
76+
const typeTag = b[p++];
77+
fields.push({
78+
name,
79+
offset,
80+
type: typeTag === 0x49 ? "i32" : `tag:0x${typeTag.toString(16)}`,
81+
});
82+
}
83+
return { version, baseAddr, fields };
84+
}
85+
86+
/**
87+
* A loaded, running TEA application.
88+
*
89+
* Lifecycle: `await TeaApp.load(src)` → `app.init()` → `app.dispatch(msg)`
90+
* (each call drives one TEA update cycle) → `app.model()`. Or hand it to
91+
* `app.run(...)` for a managed loop.
92+
*/
93+
export class TeaApp {
94+
/** @type {WebAssembly.Instance} */ #instance;
95+
/** @type {WebAssembly.Memory} */ #memory;
96+
/** @type {TeaLayout} */ #layout;
97+
/** @type {import("../packages/affine-js/loader.js").OwnershipEntry[]} */ #ownership;
98+
/** Re-entrancy guard enforcing the Linear-msg invariant. */
99+
#inCycle = false;
100+
101+
/**
102+
* @param {WebAssembly.Instance} instance
103+
* @param {TeaLayout} layout
104+
* @param {import("../packages/affine-js/loader.js").OwnershipEntry[]} ownership
105+
*/
106+
constructor(instance, layout, ownership) {
107+
this.#instance = instance;
108+
const mem = instance.exports.memory;
109+
if (!(mem instanceof WebAssembly.Memory)) {
110+
throw new Error(
111+
"affinescript-tea: module must export 'memory' (TEA ABI)",
112+
);
113+
}
114+
this.#memory = mem;
115+
if (!layout) {
116+
throw new Error(
117+
"affinescript-tea: module has no 'affinescript.tea_layout' custom " +
118+
"section — not a TEA-conformant module",
119+
);
120+
}
121+
this.#layout = layout;
122+
this.#ownership = ownership;
123+
for (const fn of ["affinescript_init", "affinescript_update"]) {
124+
if (typeof instance.exports[fn] !== "function") {
125+
throw new Error(`affinescript-tea: module must export '${fn}' (TEA ABI)`);
126+
}
127+
}
128+
}
129+
130+
/**
131+
* Load + instantiate a TEA-conformant module from any source/host.
132+
* @param {string | URL | Uint8Array | ArrayBuffer} source
133+
* @param {{ base?: string | URL, imports?: Record<string, Function> }} [options]
134+
* @returns {Promise<TeaApp>}
135+
*/
136+
static async load(source, options = {}) {
137+
const bytes = await readBytes(source, { base: options.base });
138+
const { instance, module } = await WebAssembly.instantiate(bytes, {
139+
env: { ...(options.imports ?? {}) },
140+
});
141+
const layout = parseTeaLayout(module);
142+
const ownership = parseOwnershipSection(module);
143+
return new TeaApp(instance, layout, ownership);
144+
}
145+
146+
/** The parsed model layout (from `affinescript.tea_layout`). */
147+
get layout() {
148+
return this.#layout;
149+
}
150+
151+
/**
152+
* The ownership annotations. `affinescript_update`'s `msg` parameter is
153+
* Linear (kind `"linear"`) — the host-visible proof that a message is
154+
* consumed exactly once per update cycle.
155+
* @type {import("../packages/affine-js/loader.js").OwnershipEntry[]}
156+
*/
157+
get ownership() {
158+
return this.#ownership;
159+
}
160+
161+
/** Read the current model as a plain object (generic, layout-driven). */
162+
model() {
163+
const dv = new DataView(this.#memory.buffer);
164+
/** @type {Record<string, number>} */
165+
const m = {};
166+
for (const f of this.#layout.fields) {
167+
m[f.name] = dv.getInt32(this.#layout.baseAddr + f.offset, true);
168+
}
169+
return m;
170+
}
171+
172+
/** Run `affinescript_init()`; returns the initial model. */
173+
init() {
174+
this.#instance.exports.affinescript_init();
175+
return this.model();
176+
}
177+
178+
/**
179+
* Drive one TEA update cycle with `msg`, then return the new model.
180+
*
181+
* Enforces the Linear-msg invariant host-side: `affinescript_update` is
182+
* invoked exactly once, and the call is non-re-entrant — dispatching
183+
* again from within a view/effect triggered by this cycle throws (that
184+
* would consume a second message inside one cycle, violating the
185+
* linearity the `affinescript.ownership` section asserts).
186+
*
187+
* @param {number} msg
188+
* @returns {Record<string, number>} the model after the update
189+
*/
190+
dispatch(msg) {
191+
if (!Number.isInteger(msg)) {
192+
throw new TypeError(`affinescript-tea: msg must be an i32, got ${msg}`);
193+
}
194+
if (this.#inCycle) {
195+
throw new Error(
196+
"affinescript-tea: re-entrant dispatch — a message must be consumed " +
197+
"exactly once per update cycle (Linear-msg invariant)",
198+
);
199+
}
200+
this.#inCycle = true;
201+
try {
202+
this.#instance.exports.affinescript_update(msg);
203+
} finally {
204+
this.#inCycle = false;
205+
}
206+
return this.model();
207+
}
208+
209+
/** Optional resize hook (present iff the module exports it). */
210+
setScreen(w, h) {
211+
const fn = this.#instance.exports.affinescript_set_screen;
212+
if (typeof fn !== "function") {
213+
throw new Error(
214+
"affinescript-tea: module does not export 'affinescript_set_screen'",
215+
);
216+
}
217+
fn(w, h);
218+
return this.model();
219+
}
220+
221+
/**
222+
* The managed run loop. Generic over the message source: `messages` is
223+
* any (async) iterable of i32 msgs (DOM events, a channel, a test array,
224+
* a timer — all adapt to this). `view(model)` is called once after
225+
* `init()` and once after every dispatched message.
226+
*
227+
* @param {{ messages: Iterable<number> | AsyncIterable<number>,
228+
* view: (model: Record<string, number>) => void }} driver
229+
* @returns {Promise<Record<string, number>>} the final model
230+
*/
231+
async run({ messages, view }) {
232+
if (typeof view !== "function") {
233+
throw new TypeError("affinescript-tea: run() needs a view(model) fn");
234+
}
235+
let model = this.init();
236+
view(model);
237+
for await (const msg of messages) {
238+
model = this.dispatch(msg);
239+
view(model);
240+
}
241+
return model;
242+
}
243+
}

0 commit comments

Comments
 (0)