Skip to content
4 changes: 4 additions & 0 deletions changelog/unreleased/1761.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- **BREAKING CHANGES:** `edag`: a chain ends by arity, not a `null` terminator —
a plain read is `['.', a, 'b']` and a terminal call step `['|()', c]`, each one
element shorter. Graphs and types written against the old spelling
(`['.', a, 'b', null]`) no longer validate.
18 changes: 9 additions & 9 deletions fjs/djs/todo/compile-modules-to-edag.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ Also introduce call operations into EDAG:

```js
['()', object, args] // f(...args)
['.', object, property, ['|()', args, null]] // o.p(...args)
['.', object, property, ['|()', args]] // o.p(...args)
```

There are two call spellings and the receiver is what tells them apart. `()` is the
Expand All @@ -260,10 +260,10 @@ call is instead the **property-access node owning its call** — the `'|()'` ste
`.` node's continuation is what carries the `this` binding, which no `()` node can.
See "Chains" in [`../../edag/README.md`](../../edag/README.md). Stage 2 needs neither
optional node (`?.`, `?.()`) nor any of the other three steps, since optional chaining
is not in its source subset; a plain property read is `['.', object, property, null]`.
is not in its source subset; a plain property read is `['.', object, property]`.

The property operand of a `.` node carrying a `'|()'` step follows **the same canonical
safety restriction as `.`** with a `null` continuation.
safety restriction as `.`** with no continuation.
In this stage that means a permitted string constant or number constant; prohibited
names, runtime-computed strings, and other unsupported property expressions are
rejected. This is the EDAG form of the method-call distinction and safety rules already
Expand All @@ -289,14 +289,14 @@ The staged work builds on the basic structural forms already being defined for E
the current DJS parser produces;
- array constructors: `['[]', [...node]]`;
- the argument array: `['args']`;
- Stage 1 property access: `['.', object, property, null]`, with the restricted
property operands described above — the `null` is the continuation operand, saying
the receiver this access produced is dropped;
- Stage 1 property access: `['.', object, property]`, with the restricted
property operands described above — the absent fourth operand is the continuation,
and leaving it out says the receiver this access produced is dropped;
- Stage 2 non-capturing functions: `['=>', null, body]` (`frame` is a general `exp` in
the schema; `null` is what *this task's* parser and interpreter are scoped to, not a
schema-level restriction);
- Stage 2 calls: `['()', callee, args]` for an ordinary call, and
`['.', object, property, ['|()', args, null]]` for a method call, with the property
`['.', object, property, ['|()', args]]` for a method call, with the property
operand using the same restriction as `.`;
- semantic sharing by node identity, serialized with DJS `const` references when
needed.
Expand Down Expand Up @@ -479,11 +479,11 @@ task; see [`bound-edag-interpreter-resources.md`](./bound-edag-interpreter-resou
- [ ] Validate that a nested function body is a disjoint EDAG scope: operation nodes
must not be shared across a function boundary, while sharing within the body is
preserved.
- [x] `['()', callee, args]` and the `['|()', args, null]` step a `.` node carries for
- [x] `['()', callee, args]` and the `['|()', args]` step a `.` node carries for
a method call are in the EDAG validation/type schema (`fjs/edag/`), shape only —
the property-operand restriction below is this stage's own work.
- [ ] Convert the corresponding parser call expressions to the EDAG call forms — `()`
for an ordinary call, a `.` node with a `['|()', args, null]` continuation for a
for an ordinary call, a `.` node with a `['|()', args]` continuation for a
method call; reject prohibited or runtime-computed string properties in that
node rather than bypassing the property-access safety rule.
- [ ] Add proofs for non-capturing nested functions and ordinary/method calls in the
Expand Down
8 changes: 4 additions & 4 deletions fjs/djs/todo/interpret-edag.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ reuse the first call's `[1]`. Sharing of a body node remains memoized within eac
individual invocation.

As the compiler lands the staged operators, the direct interpreter should support the
same EDAG forms: Stage 1 adds `.` property access with a `null` continuation; Stage 2
same EDAG forms: Stage 1 adds `.` property access with no continuation; Stage 2
adds non-capturing `=>`, the ordinary call `['()', callee, args]`, and the method call
— a `.` node whose continuation is `['|()', args, null]`, which is what carries the
— a `.` node whose continuation is `['|()', args]`, which is what carries the
`this` binding.

Stage 2 deliberately has **no frame support** — a restriction on *this interpreter*, not
Expand Down Expand Up @@ -96,9 +96,9 @@ hardening TODO after the baseline interpreter exists.
- [ ] Validate the final EDAG before interpretation.
- [ ] Interpret EDAG operations directly; do not generate JavaScript from EDAG and run
it through the host JavaScript engine.
- [ ] Support Stage 1 `['.', object, property, null]` property access.
- [ ] Support Stage 1 `['.', object, property]` property access.
- [ ] Support Stage 2 `['=>', null, body]`, `['()', callee, args]` for an ordinary
call, and `['.', object, property, ['|()', args, null]]` for a method call —
call, and `['.', object, property, ['|()', args]]` for a method call —
the step is what supplies the `this` binding — when those operators land.
- [ ] Do **not** implement `['frame']` or non-empty closure frames in Stage 2.
- [ ] Memoize results by EDAG node identity within one evaluation context so shared
Expand Down
105 changes: 61 additions & 44 deletions fjs/edag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,20 @@ vocabularies.
| `['args']` | the function's arguments |
| `['frame']` | the captured frame |
| `['()', exp, exp]` | call with no receiver: `exp0(...exp1)` — see [Chains](#chains) |
| `['.', exp, index, propertyLambda]` | property access `exp0[exp1]`, owning whatever its receiver is used for |
| `['?.', exp, index, optionPropertyLambda]` | optional property access `exp0?.[exp1]`, owning the rest of its optional region |
| `['?.()', exp, exp, optionLambda]` | optional call `exp0?.(...exp1)`, likewise |
| `['\|()', exp, k]`, `['\|.', index, k]`, `['\|?.()', exp, k]`, `['\|!()', exp, null]` | a chain step and its continuation — only valid in the continuation operand of a node above, or of another step |
| `['.', exp, index]`, `['.', exp, index, propertyLambda]` | property access `exp0[exp1]`, owning whatever its receiver is used for |
| `['?.', exp, index]`, `['?.', exp, index, optionPropertyLambda]` | optional property access `exp0?.[exp1]`, owning the rest of its optional region |
| `['?.()', exp, exp]`, `['?.()', exp, exp, optionLambda]` | optional call `exp0?.(...exp1)`, likewise |
| `['\|()', exp, k?]`, `['\|.', index, k?]`, `['\|?.()', exp, k?]`, `['\|!()', exp]` | a chain step and, where the chain continues, its continuation — only valid in the continuation operand of a node above, or of another step |
| `[',', exps]` | comma: establish all operands, take the value of the last |
| `[id, exp]` | unary operation, `id` one of `String` `Number` `neg` `!` `~` |
| `[id, exp, exp]` | binary operation, `id` one of `=>` `own` `===` `!==` `>` `>=` `<` `<=` `+` `-` `*` `/` `%` `**` `&` `\|` `^` `<<` `>>` `>>>` `&&` `\|\|` `??` |

Where a form is listed twice above, the two are the node's arities: the
shorter one ends the chain and the longer one hands it on, and the schema is
their union. A `k?` in the step row says the same thing one level down. That
is the whole of how a chain ends — there is no terminator value, so `null` in
Comment thread
sergey-shandar marked this conversation as resolved.
a continuation position is simply not one of these forms.

A `[]` suffix in the form column marks an operand that is an array of the
named schema, not one of it: `['[]', items[]]` holds a whole array of
`items`, and `exps` is likewise `exp[]`. The distinction is easy to lose in
Expand All @@ -93,9 +99,12 @@ the array operand is the decided representation rather than a stand-in for a
flat one — [`todo/edag-stage1-discussion.md`](../../todo/edag-stage1-discussion.md)
writes the same shape.

A continuation is **not** an array. It is `null` or one step holding the next
A continuation is **not** an array. It is one step holding the next
continuation, so a chain is a linked list whose link type changes as it goes —
which link type is legal where is the whole of [Chains](#chains) below.
which link type is legal where is the whole of [Chains](#chains) below. The
list ends by **arity**: the step or node that ends it is simply the shorter
tuple, with no continuation operand at all, which is why every kind that can
end is a union of its two closed lengths.

An `index` — the property operand of `.`, `?.`, and the `|.` step — is a
`string`, a `number`, or `['Number', exp]`, a computed index cast to a
Expand Down Expand Up @@ -130,7 +139,7 @@ control flow has to be born, carried, and consumed inside one node — and the
node's **continuation** operand is where it is carried. A continuation is a
*lambda*: a function of the chain's current value whose argument is elided,
which is what the name says. It is not an `exp` and cannot be lifted out as a
shared node — `['|.', 'b', null]` means nothing on its own.
shared node — `['|.', 'b']` means nothing on its own.

### Two bits, three lambda types

Expand Down Expand Up @@ -158,7 +167,7 @@ produces a bare value, which is why it alone has no continuation operand.
| `['\|.', index, k]` | sets P, keeps O | property access; the input becomes the receiver |
| `['\|()', exp, k]` | clears P, keeps O | call the current value with the current receiver |
| `['\|?.()', exp, k]` | clears P, **sets** O | the same, `undefined` on a nullish current value — and the region it opens owns the rest of the chain |
| `['\|!()', exp, null]` | clears P, **clears** O | the same as `\|()`, but *outside* the region: the parentheses ended it, so a short-circuit does not skip this step |
| `['\|!()', exp]` | clears P, **clears** O | the same as `\|()`, but *outside* the region: the parentheses ended it, so a short-circuit does not skip this step |

`?` adds a guard and `!` escapes one, which makes the three call steps a
complete taxonomy of how a call can relate to the region it sits in:
Expand Down Expand Up @@ -190,9 +199,11 @@ and which bit says why it cannot be a node instead:
| | `\|?.()` | O and P | O |
| | `\|!()` | P — the region is closing anyway | *(terminal)* |

`null` is every state's third exit — the chain simply ends and any live bit
is dropped, which is also the correct spelling of a bare `(a?.b)`, since
closing a region with nothing after it is unobservable.
Leaving the continuation operand out is every state's third exit — the chain
simply ends and any live bit is dropped, which is also the correct spelling of
a bare `(a?.b)`, since closing a region with nothing after it is
unobservable. Ending is therefore an absence, not a value: `null` is a
primitive again, and it has no reading in a continuation position.

What is *absent* carries as much as what is present. `|!()` outside a region
is not a design decision — there is no bit to clear. The three real decisions
Expand All @@ -208,24 +219,24 @@ throws where `a?.b.c` does not.

| JS | EDAG |
|---|---|
| `a.b` | `['.', a, 'b', null]` |
| `a.b.c` | `['.', ['.', a, 'b', null], 'c', null]` |
| `a.b(...c)` | `['.', a, 'b', ['\|()', c, null]]` |
| `(0, a.b)(...c)` | `['()', ['.', a, 'b', null], c]` |
| `a.b?.(...c)` | `['.', a, 'b', ['\|?.()', c, null]]` |
| `a.b` | `['.', a, 'b']` |
| `a.b.c` | `['.', ['.', a, 'b'], 'c']` |
| `a.b(...c)` | `['.', a, 'b', ['\|()', c]]` |
| `(0, a.b)(...c)` | `['()', ['.', a, 'b'], c]` |
| `a.b?.(...c)` | `['.', a, 'b', ['\|?.()', c]]` |
| `f(...c)` | `['()', f, c]` |
| `a?.b` | `['?.', a, 'b', null]` |
| `a?.b.c` | `['?.', a, 'b', ['\|.', 'c', null]]` |
| `(a?.b).c` | `['.', ['?.', a, 'b', null], 'c', null]` |
| `a?.b(...c)` | `['?.', a, 'b', ['\|()', c, null]]` |
| `a?.b?.(...c)` | `['?.', a, 'b', ['\|?.()', c, null]]` |
| `(a?.b)(...c)` | `['?.', a, 'b', ['\|!()', c, null]]` |
| `(a?.b.c)(...d)` | `['?.', a, 'b', ['\|.', 'c', ['\|!()', d, null]]]` |
| `(a?.b).c(...d)` | `['.', ['?.', a, 'b', null], 'c', ['\|()', d, null]]` |
| `a?.b(...c).d(...e)` | `['?.', a, 'b', ['\|()', c, ['\|.', 'd', ['\|()', e, null]]]]` |
| `a?.(...c)` | `['?.()', a, c, null]` |
| `a?.(...c).d` | `['?.()', a, c, ['\|.', 'd', null]]` |
| `(a?.(...c))(...d)` | `['()', ['?.()', a, c, null], d]` |
| `a?.b` | `['?.', a, 'b']` |
| `a?.b.c` | `['?.', a, 'b', ['\|.', 'c']]` |
| `(a?.b).c` | `['.', ['?.', a, 'b'], 'c']` |
| `a?.b(...c)` | `['?.', a, 'b', ['\|()', c]]` |
| `a?.b?.(...c)` | `['?.', a, 'b', ['\|?.()', c]]` |
| `(a?.b)(...c)` | `['?.', a, 'b', ['\|!()', c]]` |
| `(a?.b.c)(...d)` | `['?.', a, 'b', ['\|.', 'c', ['\|!()', d]]]` |
| `(a?.b).c(...d)` | `['.', ['?.', a, 'b'], 'c', ['\|()', d]]` |
| `a?.b(...c).d(...e)` | `['?.', a, 'b', ['\|()', c, ['\|.', 'd', ['\|()', e]]]]` |
| `a?.(...c)` | `['?.()', a, c]` |
| `a?.(...c).d` | `['?.()', a, c, ['\|.', 'd']]` |
| `(a?.(...c))(...d)` | `['()', ['?.()', a, c], d]` |

The `chains` section of [proof.f.mjs](proof.f.mjs) pins the shape of every
spelling above, `chainsJs` next to it runs them as JS on the host engine, and
Expand All @@ -247,19 +258,22 @@ section of [proof.f.mjs](proof.f.mjs) is one case per family.
The same holds for dead prefixes: `propertyLambda` has no `|.` production, so
plain property paths nest and `a.b.c` has exactly one spelling. "Exactly one"
is literal rather than "up to trailing junk", because every tuple in the
schema is closed — `['.', a, 'b', null, 'extra']` does not validate.
schema is closed — `['.', a, 'b', k, 'extra']` does not validate.

Two things the vocabulary makes disjoint deserve stating, because neither is
cosmetic. **The `|` prefix is a correctness requirement.** Unprefixed,
`['()', f, null]` would be simultaneously a well-formed `()` node — call `f`
with `null` as its arguments — and a well-formed `optionLambda` — call the
chain's value with `f` as its arguments, and stop. The two readings have the
same length, so closedness could not have separated them — it bounds a tuple's
length and says nothing about its tag; only disjoint vocabularies can. **Terminals state their `null`.** `propertyLambda`'s `|()`
and `optionPropertyLambda`'s `|!()` end the chain, and they say so with an
explicit third operand rather than by being one element shorter: a
two-element terminal handed a real continuation would validate as the
terminal with the rest silently dropped.
`['()', f, k]` would read as a well-formed `()` node — call `f` with `k` as
its arguments — and as a well-formed step — call the chain's value with `f` as
its arguments, then continue with `k`. Closedness bounds a tuple's length and
says nothing about its tag, so no arity separates those readings; only
disjoint vocabularies can, and the prefix does it without anyone having to
prove that a continuation could never also be an expression.
**Closedness by length is what a terminal rests on.** `propertyLambda`'s
`|()` and `optionPropertyLambda`'s `|!()` end the chain and have only the
two-element arity, so a continuation handed to one is a third element the
tuple does not declare, and the value is rejected rather than accepted with
the rest silently dropped — which is exactly what the length check gives and
what an `open` tuple would take away.

### Where the host engines disagree

Expand All @@ -269,7 +283,7 @@ chain, so `undefined` is called. V8 does throw; JavaScriptCore (hence
`bun test`) carries the short-circuit through the parentheses and evaluates to
`undefined` instead. That case is exactly the `|!()` step, so no JavaScript
oracle can establish it on every supported runner. The EDAG follows the
specification — `['?.', u, 'b', ['|!()', d, null]]` denotes the throwing
specification — `['?.', u, 'b', ['|!()', d]]` denotes the throwing
reading, and an executor must produce it whatever its host does — as
[amnesia](amnesia/module.f.mjs) does, where
`optionRegion.throw.closeStepOnUndefined` in
Expand Down Expand Up @@ -309,14 +323,17 @@ unblocks them, in

### The cost

Every property access carries a continuation operand, so a plain `a.b` is
`['.', a, 'b', null]` in every graph: more tuple elements to store and hash,
though no ambiguity, since `propertyLambda` has no `|.` production and a
property path keeps its unique spelling.
A plain `a.b` is `['.', a, 'b']`, so a property access that ends its chain
costs nothing beyond the access itself — the continuation operand is present
only where a chain actually continues. The price is paid in the schema
instead: every kind that can end is written twice, once per arity, so the
shared prefix appears in both arms. There is no ambiguity, since
`propertyLambda` has no `|.` production and a property path keeps its unique
spelling.

The deeper cost is purity, and it is unchanged from any other shape that
spells chains out of steps. A continuation is structured now, but it is still
not an `exp`: the `a.b` inside `['.', a, 'b', ['|?.()', c, null]]` cannot be
not an `exp`: the `a.b` inside `['.', a, 'b', ['|?.()', c]]` cannot be
shared, substituted, or hashed. That is the price of expressing control flow
that no value can carry, and it is confined to exactly the positions that
need it.
Expand Down
26 changes: 23 additions & 3 deletions fjs/edag/amnesia/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,32 @@ meaning. Two of its sections, `ownJs` and `chainsJs`, have to run **JavaScript**
to pin the behavior the nodes are built around, because until this module
existed nothing could run an EDAG. Its own [`proof.f.mjs`](./proof.f.mjs) is
what that gap was waiting for: `['+', 2, 3]` is `5` and
`['&&', false, ['.', null, 'x', null]]` short-circuits are now claims a test
`['&&', false, ['.', null, 'x']]` short-circuits are now claims a test
makes by evaluating the node, not by evaluating the JavaScript it was modeled
on.

## Why it is not a VM

### It trusts its host

Every node and step is read by **destructuring** (`const [o, e, cont] = k`),
which is what lets a chain end by arity: the array iterator stops at `length`,
so an absent continuation reads as `undefined` rather than as whatever a
prototype supplies at that index — an indexed `k[2]` would read the prototype,
which is why none appears.

That choice is not a hardening claim, and no read style would be one. Under a
hostile host each has its own hole: an unchecked index reads through the
prototype, and destructuring dispatches an own `Symbol.iterator`, which can
yield a step past the length `validate` bounded. Neither is peculiar to
ending by arity — the walkers destructured before it too — and closing them
means a hermetic read path, which is
[rtti's tracked question](../../rtti/todo/hostile-accessor-hermetic-read-path.md)
for the readers and a VM's problem for an executor. This evaluator's
guarantees assume what the language itself can build: a DJS value on a
pristine host, where every read style coincides. That is one more reason it
is not a VM, and nothing that matters should run on it.

### It forgets — hence the name

The model memoizes every node by identity within one invocation, so a shared
Expand Down Expand Up @@ -54,8 +74,8 @@ preserve identity, and what each is for, are in
`.` is `a[b]`, so the entire JavaScript prototype chain is reachable:

```js
vm(context)(['.', ['=>', ['[]', []], 1], 'constructor', null]) // Function
vm(context)(['.', ['{}', []], '__proto__', null]) // resolves
vm(context)(['.', ['=>', ['[]', []], 1], 'constructor']) // Function
vm(context)(['.', ['{}', []], '__proto__']) // resolves
```

[`spec/todo/2360-built-in.md`](../../../spec/todo/2360-built-in.md) lists both
Expand Down
Loading
Loading