Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions contracts/descriptor-ir.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,38 @@ optimization without changing this contract's semantics.)

Host-facing value conventions are `contracts/embedder-api.md`'s territory
(implemented by the bindgen-generated layer). The raw executor boundary
produces the `definitions.py` interpreter shapes — variant as single-key
`{label: payload}`, enum as `{label: null}`, option as
`{none: null} / {some: v}`, result error key `"error"`, tuple as
despecialized record — and is an internal surface with no stability
promise. Integer lanes wrap mod 2⁶⁴ at the raw boundary, matching
mirrors `definitions.py`'s value *semantics*; its *representation* is this
implementation's to choose, and diverges deliberately where measurement
justifies it (docs/architecture.md §1 sanctions exactly this — "parity
means functional parity, not behavioral identity", and lists JS-native
host value shapes among the divergences). The shapes are: variant as
`{kind: label, value: payload}`, enum as `{kind: label, value: null}`,
option as `{kind: "none", value: null} / {kind: "some", value: v}`, result
error kind `"error"`, tuple as despecialized record. It is an internal
surface with no stability promise.

Integer lanes wrap mod 2⁶⁴ at the raw boundary, matching
definitions.py's `% 2**64`; host-side range *asserts* (host-precondition
errors, not traps) exist only on the scalar `storeInt` path. NaN handling,
lane widening/padding (i64 lanes as `bigint`, `0n` padding), and
latin1(windows-1252) details follow the decisions recorded in
`runtime/README.md` and docs/architecture.md §7.

The variant family carries its case in a `kind` property rather than as the
object's sole key — the one place the representation departs from the
reference's dicts. `definitions.py` uses a single-key mapping; that form
cost a computed-key literal here (a distinct hidden class per case label,
so every variant-reading site went megamorphic) plus an `Object.keys()`
allocation at each end to read one key. `value` is always present, `null`
for a payload-free case: omitting it to match the host layer exactly was
measured slower, because the producer site then emits two shapes instead
of one. Both findings are measured on `bench/boundary`'s compound-element
lanes; see issue #261 and the PR that landed this for the numbers and
method. The property names deliberately match `contracts/embedder-api.md`'s
host variant shape, but **the two are not interchangeable** — that
document's "Implementation strategy" enumerates every way they still
differ.

## Trap discipline

Lift/lower failures raise the runtime's `ComponentTrap` (not arbitrary
Expand Down
43 changes: 34 additions & 9 deletions contracts/embedder-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ property, but `v.case` reads like syntax. The value of `kind` is always
the case name, kebab-case verbatim.

**Why a discriminant property rather than `{ [case]: value }`** (the
single-key form the internal definitions.py-shaped boundary uses):
single-key form the internal boundary used until issue #261):
(1) exhaustiveness — `switch (v.kind)` + `assertNever` is compiler-checked
case coverage; `in`-chains are not switchable and lose it; (2) payloadless
cases get one uniform shape (`value` absent) instead of a null/undefined
Expand All @@ -145,6 +145,12 @@ regime as keys. Conceded cost: literal construction is wordier —
bindgen may emit per-variant constructor helpers (`Message.binary(bytes)`)
as an optional nicety; the value shape is unaffected.

Reason (3) turned out to be measurable rather than merely tidy, and the
internal boundary has since adopted the same `kind` discriminant for it
(issue #261, `contracts/descriptor-ir.md` §"Host value shapes"). It keeps
`value: null` for payloadless cases where this layer omits the property —
one shape per producer site measured faster than exact convergence.

**Option rule.** The *outermost* option in a chain maps to
`T | undefined`; every option nested **directly inside another option**
uses the variant family: `{ kind: "some", value: … } | { kind: "none" }`.
Expand Down Expand Up @@ -1320,14 +1326,33 @@ raw (definitions.py-shaped) boundary — see below.
## Implementation strategy

The ergonomic layer is generated code **on top of** the raw boundary; the
interpreter's internal shapes (single-key variants, `{some}/{none}`,
tuple-as-record) are pinned by the reference-test ports and the
conformance harness's value mapping — converging the interpreter itself
is a perf-track concern (the descriptor-driven codegen executor can emit
convention shapes directly, skipping the adapter). Consequence:
`instance.exports` stays internal-shaped and documented as such;
embedders use the bindgen layer (or accept the internal surface with no
stability promise).
interpreter's internal shapes are pinned by the reference-test ports and
the conformance harness's value mapping. Issue #261 converged one of them
for measured reasons: the interpreter's variant family now carries its
case in a `kind` property with a `value` alongside, the same property
names this layer uses.

**The convergence is partial, and the residue is a trap.** An internal
value and a host value can now be structurally identical and still mean
different things, so four differences that a mismatched shape used to make
obvious are no longer visible at a glance, and every site handling them
must translate deliberately:

- **`result`** — internal despecialization names the error case `"error"`
(definitions.py); this layer's kind is `"err"`.
- **`enum`** — internal is a variant value; here an enum is a bare string,
kebab-case verbatim.
- **`option`** — internal is always `{kind: "none" | "some", value}`; here
the outermost option in a chain is `T | undefined`, and only an option
nested directly inside another option boxes.
- **payload-free cases** — internal keeps `value: null`; this layer omits
the property.

Fully converging the interpreter remains a perf-track concern (the
descriptor-driven codegen executor can emit convention shapes directly,
skipping the adapter). Consequence: `instance.exports` stays
internal-shaped and documented as such; embedders use the bindgen layer
(or accept the internal surface with no stability promise).

## The WASI parking kernel

Expand Down
5 changes: 3 additions & 2 deletions harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ consumed read-only — this is Track A's territory):
### Value comparison

`src/value-mapping.ts` converts both directions against the runtime's
`ComponentValue` (definitions.py host shapes — variant/enum/option/result as
single-key `{label: payload}` objects, tuple as despecialized record,
`ComponentValue` (definitions.py's semantics, our representation —
variant/enum/option/result as
`{kind: label, value: payload}` objects, tuple as despecialized record,
flags as `{label: boolean}`, `list<u8>` as `Uint8Array`): `toComponentValue`
for invoke arguments, `compareValue`/`compareValues` for `assert_return`
(recursive, type-directed by the *expected* value's own tag — no separate
Expand Down
43 changes: 22 additions & 21 deletions harness/src/value-mapping.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Bidirectional mapping between wast-JSON `Value` (harness/src/schema.ts —
// scalars as decimal strings, floats as bit patterns) and the runtime's
// `ComponentValue` (runtime/src/cabi/types.ts — definitions.py host shapes:
// variant/enum/option/result as single-key `{label: payload}` objects with
// `ComponentValue` (runtime/src/cabi/types.ts — definitions.py's semantics in
// our own representation, contracts/descriptor-ir.md §"Host value shapes":
// variant/enum/option/result as `{kind: label, value: payload}` objects with
// despecialized labels `none`/`some`/`ok`/`error`, tuple as despecialized
// record `{"0": v, ...}`, flags as `{label: boolean}`).
//
Expand Down Expand Up @@ -86,7 +87,7 @@ export function toComponentValue(v: Value): any {
case "string":
return v.value as string;
case "enum":
return { [v.value as string]: null };
return { kind: v.value as string, value: null };
case "list":
case "tuple": {
const items = (v.value as Value[]).map(toComponentValue);
Expand All @@ -108,17 +109,19 @@ export function toComponentValue(v: Value): any {
const payload = v.value === null
? null
: toComponentValue(v.value as unknown as Value);
return { [v.case as string]: payload };
return { kind: v.case as string, value: payload };
}
case "option":
return v.value === null
? { none: null }
: { some: toComponentValue(v.value as unknown as Value) };
? { kind: "none", value: null }
: { kind: "some", value: toComponentValue(v.value as unknown as Value) };
case "result": {
const payload = v.value === null
? null
: toComponentValue(v.value as unknown as Value);
return v.status === "ok" ? { ok: payload } : { error: payload };
// Internal spelling of the error case is "error", not "err"
// (contracts/descriptor-ir.md §"Host value shapes").
return { kind: v.status === "ok" ? "ok" : "error", value: payload };
}
case "flags": {
const set = new Set(v.value as string[]);
Expand Down Expand Up @@ -240,8 +243,7 @@ export function compareValue(
if (typeof actual !== "object" || actual === null) {
return `${where}: expected enum object, got ${JSON.stringify(actual)}`;
}
const keys = Object.keys(actual);
const label = keys[0];
const label = (actual as Record<string, unknown>).kind;
return label === expected.value
? undefined
: `${where}: expected enum '${expected.value}', got '${label}'`;
Expand Down Expand Up @@ -293,20 +295,19 @@ export function compareValue(
return `${where}: expected variant object, got ${JSON.stringify(actual)}`;
}
const rec = actual as Record<string, unknown>;
const keys = Object.keys(rec);
if (keys.length !== 1) {
return `${where}: expected single-key variant object, got ${
const label = rec.kind;
if (typeof label !== "string") {
return `${where}: expected a { kind, value } variant object, got ${
JSON.stringify(actual)
}`;
}
const [label] = keys;
if (label !== expected.case) {
return `${where}: expected variant case '${expected.case}', got '${label}'`;
}
if (expected.value === null) return undefined;
return compareValue(
expected.value as unknown as Value,
rec[label],
rec.value,
`${where}.${label}`,
);
}
Expand All @@ -316,34 +317,34 @@ export function compareValue(
}
const rec = actual as Record<string, unknown>;
if (expected.value === null) {
return "none" in rec
return rec.kind === "none"
? undefined
: `${where}: expected none, got ${JSON.stringify(actual)}`;
}
if (!("some" in rec)) {
if (rec.kind !== "some") {
return `${where}: expected some(...), got ${JSON.stringify(actual)}`;
}
return compareValue(expected.value as unknown as Value, rec.some, `${where}.some`);
return compareValue(expected.value as unknown as Value, rec.value, `${where}.some`);
}
case "result": {
if (typeof actual !== "object" || actual === null) {
return `${where}: expected result object, got ${JSON.stringify(actual)}`;
}
const rec = actual as Record<string, unknown>;
if (expected.status === "ok") {
if (!("ok" in rec)) {
if (rec.kind !== "ok") {
return `${where}: expected ok(...), got ${JSON.stringify(actual)}`;
}
if (expected.value === null) return undefined;
return compareValue(expected.value as unknown as Value, rec.ok, `${where}.ok`);
return compareValue(expected.value as unknown as Value, rec.value, `${where}.ok`);
}
if (!("error" in rec)) {
if (rec.kind !== "error") {
return `${where}: expected error(...), got ${JSON.stringify(actual)}`;
}
if (expected.value === null) return undefined;
return compareValue(
expected.value as unknown as Value,
rec.error,
rec.value,
`${where}.error`,
);
}
Expand Down
5 changes: 3 additions & 2 deletions runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ distinctions); flagged for plan review:
loss could matter.
6. **`String.prototype.toWellFormed()`** (ES2024) implements the USVString
conversion; present in all JSPI-capable engines (the compatibility floor).
7. **Variant/record/flags value shapes** mirror definitions.py (single-key
objects, despecialized tuple records, label→bool maps); `list<u8>` is
7. **Variant/record/flags value shapes** mirror definitions.py's semantics
(variants as `{kind, value}` objects — contracts/descriptor-ir.md §"Host
value shapes" — despecialized tuple records, label→bool maps); `list<u8>` is
`Uint8Array` per docs/architecture.md §7. Final host-facing representations for bindgen
remain open (below).

Expand Down
2 changes: 1 addition & 1 deletion runtime/src/cabi/lift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export function liftFlatVariant(
for (const have of flatTypes) {
vi.next(have);
}
return { [c.label]: v };
return { kind: c.label, value: v };
}

export function wrapI64ToI32(i: bigint): number {
Expand Down
4 changes: 2 additions & 2 deletions runtime/src/cabi/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ export function loadVariant(
const caseIndex = loadIntU(mem, ptr, discSize);
trapIf(caseIndex >= cases.length, "invalid variant discriminant");
const c = cases[caseIndex];
if (c.type === null) return { [c.label]: null };
return { [c.label]: load(cx, ptr + payloadOffset, c.type) };
if (c.type === null) return { kind: c.label, value: null };
return { kind: c.label, value: load(cx, ptr + payloadOffset, c.type) };
}

export function loadFlags(
Expand Down
5 changes: 3 additions & 2 deletions runtime/src/cabi/lower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
despecialize,
type FieldType,
type ValType,
type VariantValue,
} from "./types.ts";
import {
lowerErrorContext,
Expand Down Expand Up @@ -68,7 +69,7 @@ export function lowerFlat(
case "record":
return lowerFlatRecord(cx, v as Record<string, ComponentValue>, d.fields);
case "variant":
return lowerFlatVariant(cx, v as Record<string, ComponentValue>, d.cases);
return lowerFlatVariant(cx, v as VariantValue, d.cases);
case "flags":
return lowerFlatFlags(v as Record<string, ComponentValue>, d.labels);
case "own":
Expand Down Expand Up @@ -144,7 +145,7 @@ export function lowerFlatRecord(

export function lowerFlatVariant(
cx: LiftLowerContext,
v: Record<string, ComponentValue>,
v: VariantValue,
cases: CaseType[],
): CoreValue[] {
const [caseIndex, caseValue] = matchCase(v, cases);
Expand Down
19 changes: 10 additions & 9 deletions runtime/src/cabi/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type ComponentValue,
type FieldType,
type ValType,
type VariantValue,
} from "./types.ts";
import {
lowerErrorContext,
Expand Down Expand Up @@ -106,7 +107,7 @@ export function store(
case "variant":
storeVariant(
cx,
v as Record<string, ComponentValue>,
v as VariantValue,
ptr,
d.cases,
// 0 only on the kinds without a discriminant; see loadVariant's note.
Expand Down Expand Up @@ -222,25 +223,25 @@ export function storeRecord(
}
}

/** definitions.py match_case: the value is a single-key object. */
/** definitions.py match_case, over the `{kind, value}` variant shape. */
export function matchCase(
v: Record<string, ComponentValue>,
v: VariantValue,
cases: CaseType[],
): [number, ComponentValue] {
const keys = Object.keys(v);
assert_(keys.length === 1, "variant value must have exactly one case");
const label = keys[0];
const label = v.kind;
// `caseIndexOf` is the memoized form of the linear scan this used to run on
// every variant stored (issue #261); it maps a duplicated label to -1, so
// the "exactly one match" condition below is unchanged.
// the "exactly one match" condition below is unchanged. A missing or
// non-string `kind` cannot be a key of that Map either, so this one assert
// covers a malformed value too.
const i = caseIndexOf(cases).get(label);
assert_(i !== undefined && i >= 0, `variant case '${label}' not found`);
return [i as number, v[label]];
return [i as number, v.value];
}

export function storeVariant(
cx: LiftLowerContext,
v: Record<string, ComponentValue>,
v: VariantValue,
ptr: number,
cases: CaseType[],
discSize: 1 | 2 | 4,
Expand Down
22 changes: 21 additions & 1 deletion runtime/src/cabi/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export interface FuncType {
* list<u8> -> Uint8Array (copy; docs/architecture.md §7) — other lists -> Array
* record -> { [fieldLabel]: value }
* tuple -> despecialized record { "0": v0, "1": v1, ... }
* variant/enum/option/result -> single-key object { [caseLabel]: payload|null }
* variant/enum/option/result -> VariantValue { kind: caseLabel, value: payload|null }
* flags -> { [label]: boolean }
* own/borrow -> number (the resource rep at this layer)
* These mirror definitions.py's Python shapes; final host-facing bindings
Expand Down Expand Up @@ -241,6 +241,26 @@ export interface AsyncValue {
readonly __asyncValue?: never;
}

/**
* The internal shape of the whole despecialized variant family — plain
* `variant`, `enum`, `option`, `result` (error case spelled `"error"`).
* `value` is always present, `null` for a payload-free case.
*
* **Not interchangeable with the host variant shape** despite the matching
* property names: `contracts/embedder-api.md` §"Implementation strategy"
* enumerates the three asymmetries (`result`, `enum`, `option`) plus the
* payload-free spelling. Translate deliberately; never pass one through as
* the other.
*
* Declared as a type alias, not an interface, deliberately: only an alias
* gets TypeScript's implicit index signature, which is what keeps it
* assignable to `ComponentValue`'s record arm.
*/
export type VariantValue = {
kind: string;
value: ComponentValue;
};

export type ComponentValue =
| AsyncValue
| boolean
Expand Down
Loading
Loading