Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
61f990e
emergent_testing: the browser runs the shared traversal
claude Aug 28, 2026
6f0f721
changelog: name the entry by its real PR number
claude Aug 28, 2026
8f86c14
Merge remote-tracking branch 'origin/main' into claude/todo-unificati…
claude Aug 28, 2026
93171a7
emergent_testing: read each browser proof export exactly once
claude Aug 28, 2026
a1c4cd8
emergent_testing: join a walk's outcomes once, not pairwise
claude Aug 28, 2026
7cd8db9
emergent_testing: follow the example for the browser catch, and recon…
claude Aug 28, 2026
961e885
emergent_testing: join a walk's records as a rope, not an array
claude Aug 28, 2026
1580f06
emergent_testing: drop the unused list import
claude Aug 28, 2026
da878ca
emergent_testing: a run that fails as a runner still reports
claude Aug 28, 2026
74202d5
effects/browser: the runner keeps the effect's types
claude Aug 28, 2026
3480379
effects/browser: assert through the shared helper
claude Aug 28, 2026
1724d9a
Merge branch 'main' into claude/todo-unification-runners-7wvd1q
sergey-shandar Aug 28, 2026
668eee0
effects/browser: an extra operation may not claim a core one
claude Aug 28, 2026
a98bbb8
emergent_testing: the todo says one thing about the operation list
claude Aug 28, 2026
cb40800
effects/browser: carry extra handlers by descriptor
claude Aug 28, 2026
affbc2f
effects/browser: read the extra map once
claude Aug 28, 2026
bb14a50
emergent_testing: record where progress rendering would belong
claude Aug 28, 2026
cab8ce0
effects/browser: give the thread back on a frame budget
claude Aug 28, 2026
d3c9ac1
Merge branch 'main' into claude/todo-unification-runners-7wvd1q
sergey-shandar Aug 28, 2026
763ee3b
emergent_testing: a run starts after its promise is published
claude Aug 28, 2026
69895e2
emergent_testing: the todo says one thing about browser scheduling
claude Aug 28, 2026
4eb7012
effects/browser: charge every operation to the frame budget
claude Aug 28, 2026
7387fb1
effects: record the argument limit on `all`
claude Aug 28, 2026
f6e00d2
effects: name every spread that shares the `all` ceiling
claude Aug 28, 2026
ac07427
effects/browser: prove the charging by ordering, not by coincidence
claude Aug 28, 2026
5d14d55
Merge branch 'main' into claude/todo-unification-runners-7wvd1q
sergey-shandar Aug 28, 2026
4188b18
emergent_testing: revert the sharing code, keep everything it taught
claude Aug 28, 2026
80ed2d9
todo: the plan says one thing everywhere
claude Aug 28, 2026
727cc73
todo: linking is before the skeleton, not a part of it
claude Aug 28, 2026
15b6384
todo: the duration gap outlives the concurrency that named it
claude Aug 28, 2026
c12fe18
todo: one home for `all`, and loading is outside the timer
claude Aug 28, 2026
7e3840c
todo: three more one-answer fixes from the requested review
claude Aug 29, 2026
ec31c71
todo: Await gets its policy, and the all migration names its break
claude Aug 29, 2026
c3183d0
todo: the sequential decision reaches the issues that link here
claude Aug 29, 2026
9384320
todo: three more edges of the same decisions
claude Aug 29, 2026
703cd1e
todo: two corrections and one honest application of my own test
claude Aug 29, 2026
63da0e4
todo: All passes the test too, and 7b names its ordering change
claude Aug 29, 2026
c5f74d2
todo: two claims caught describing the reverted code as current
claude Aug 29, 2026
0853658
todo: reconcile the re-export test and the tree walker with the seque…
claude Aug 29, 2026
ebd418a
todo: the start record is a complete line, not an open one
claude Aug 29, 2026
8654c2a
todo: the port is a behavior change for the page, named as such
claude Aug 29, 2026
75f5aca
DESIGN, todo: separation forbids the combination, not a fixed order
claude Aug 29, 2026
6db0651
DESIGN: the workflow's 'behaviour unchanged' is as of the port
claude Aug 29, 2026
36267c2
Merge branch 'main' into claude/todo-unification-runners-7wvd1q
sergey-shandar Aug 29, 2026
fda6231
Merge branch 'main' into claude/todo-unification-runners-7wvd1q
sergey-shandar Aug 29, 2026
ae5da84
todo: errorExit passes the re-export test; the code counts, not just …
claude Aug 29, 2026
e9f2ff5
Merge branch 'claude/todo-unification-runners-7wvd1q' of https://gith…
claude Aug 29, 2026
35dd71b
Merge branch 'main' into claude/todo-unification-runners-7wvd1q
sergey-shandar Aug 29, 2026
5c7ec93
todo: 7a's sequential contract needs a proof that fails when work ove…
claude Aug 29, 2026
8cac4ce
Merge branch 'claude/todo-unification-runners-7wvd1q' of https://gith…
claude Aug 29, 2026
2731990
todo: allVoid is born list-shaped, not migrated
claude Aug 29, 2026
bd4cf24
todo: name both callables so allVoid builds in every permitted branch
claude Aug 29, 2026
6eeaf05
todo: allReduce is built on allOk with a parameterized error channel
claude Aug 29, 2026
2aa7eba
todo: cancellation boundaries and the timing experiment follow sequen…
claude Aug 29, 2026
f71f6fe
todo: the re-export test runs post-move; cancellation checks precede …
claude Aug 29, 2026
f40d23e
todo: prove the run-failure guard in 7b, through a minimal seam
claude Aug 29, 2026
ee99192
todo: the browser start handler yields before the proof runs
claude Aug 29, 2026
9ff8ad2
todo: the yields and the guard get ordering proofs, one per route
claude Aug 29, 2026
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
28 changes: 22 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,13 @@ supplies — rather than a special case.
The order of work follows from that:

1. **Share the skeleton.** Take the existing implementation as the core, with
its behaviour unchanged.
its behaviour unchanged — unchanged *as it stands when the port begins*.
When an idea lands first in the existing context (the idea-first order
below), the core the port takes already carries it, and the port copies
that. Such a port does acquire the new policy, and stays separate from it
all the same: the policy was argued, landed and proved in its own change,
in the context that could prove it, so the port's argument is only the
port.
2. **Adjust the parts** the new context genuinely requires, or extend the
skeleton so it can express what the new context needs.
3. **Document every difference that remains,** at the part where it is made.
Expand Down Expand Up @@ -173,11 +179,21 @@ the new context is often a decision made in the old one. Copy it first; if it
turns out to be wrong, it is wrong in both places and worth an issue that says
so.

**Keep the port separate from everything it inspires.** Land the sharing change
on its own, with behaviour unchanged. Anything new — a different scheduling
policy, a better measurement, an extra guard — is its own change afterwards.
Combined, they cannot be reviewed: an argument about the new idea becomes an
argument about the port.
**Keep the port separate from everything it inspires.** Anything new — a
different scheduling policy, a better measurement, an extra guard — is its own
change, never part of the port. Combined, they cannot be reviewed: an argument
about the new idea becomes an argument about the port. What the rule forbids is
the combination, not a fixed order. The common order is port first, behaviour
unchanged, because the port is usually what reveals the idea. When the idea is
the *premise* — decided before any port, and provable in the existing context
on its own — the same separation runs the other way: land the idea first, in
the context that can prove it, then the port, which then carries no idea of its
Comment thread
sergey-shandar marked this conversation as resolved.
own beyond what the shared code already does. (An earlier version of this rule
said "with behaviour unchanged... afterwards", prescribing the order; the
sequential-runner plan in
[share-browser-console-runner](fjs/emergent_testing/todo/share-browser-console-runner.md)
is the case that showed the order is the consequence, not the rule — porting
first would have moved a context onto semantics about to change under it.)

### Exception to DRY: performance measurement

Expand Down
120 changes: 120 additions & 0 deletions fjs/effects/todo/all-argument-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
## all-argument-limit. `all` cannot fan out more siblings than the engine allows arguments

**Priority:** P3
**Status:** open

### Problem

`All` is declared variadic — `readonly['all', <T, E>(...effects: Effect<never, T, E>[]) => …]` — so
every fan-out reaches it as a spread, and each one is a separate instance of the same
ceiling. Every site in the repository today:

| site | what it fans out |
|-|-|
| `emergent_testing/module.f.mjs` `walkEntries` | one module's sibling leaves |
| `emergent_testing/module.f.mjs` `runModuleMap` | the modules of a run |
| `emergent_testing/module.f.mjs` `registerModule` ×2, `registerModuleMap` | the same two, for the framework-registration path |
| `dev/module.f.mjs` ×2 | files to load, and their imports |

They fail independently: a suite of a hundred thousand *modules* breaks the outer spread
however few leaves each holds, and one module of a hundred thousand leaves breaks the inner
one however few modules there are. A fix has to be the operation's, not a site's.

A spread is a call, and a call has an argument limit. Measured on node 22:

| siblings | result |
|-|-|
| 50,000 | ok |
| 100,000 | `RangeError: Maximum call stack size exceeded` |

The throw is in **building** the effect, before any interpreter sees it, so no runner can
recover from it and no `catch` operation is in the path. Today only `fjs t` is on this
path, and it panics. (The reverted functionalscript#1759 briefly put the browser page on
it too, where the page's run-failure guard reported one `infrastructure-error` — the guard
working as intended, but not an answer; the current page takes the `Promise.all` path
below and never builds the effect.)

The ceiling applies **per fan-out**, and a run has two: one module with too many sibling
leaves breaks the inner spread, and a run with too many *modules* breaks the outer one in
`runModuleMap` — the independence the table above states. Nothing in this repository is
close to either — the browser suite is 3,461 leaves across 138 modules, three orders of
magnitude under both — so this is a real ceiling rather than a live problem, and it is
recorded rather than fixed for that reason.

The browser runner is immune for a reason that has nothing to do with its batching:
`Promise.all(batch.map(…))` passes one iterable argument, so no spread exists there at any
batch size — the ceiling is the *variadic operation's*, not fan-out's in general. (An
earlier version of this paragraph credited `batchSize = 25` with staying under the limit;
that was a misattribution, corrected in the pitfall catalog in
[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md).)
The reverted functionalscript#1759 routed the page through the shared traversal and so
briefly gave both runners the same ceiling; the sequential plan that replaced it removes
the traversal's fan-outs entirely, which retires the `walkEntries` and `runModuleMap` rows
above. What remains then is the registration path and `dev` — still the operation's
problem, at fewer sites.

### Proposal

Make `all` take a list rather than an argument list:

```ts
export type All = readonly['all', <T, E>(effects: readonly Effect<never, T, E>[]) => OpResult<readonly Result<T, E>[]>]
```

Then `allOk(entries.map(one))` builds an array and hands it over, and no call in the path
grows with the suite. Every `all` handler changes shape — `effects/node`'s real and
virtual runners, the mock, and any fixture that supplies one — which is what makes this
its own step rather than a fix inside another change. Not a browser interpreter: under
the sequential plan the traversal performs no `all`, so no browser implements it.

The variadic spelling is nicer at the two-or-three-effect call sites that motivated it
(`both`, hand-written fan-outs in proofs), so a wrapper that keeps that shape over the
list-shaped operation is worth having in the same change. **Both callables get
unambiguous names, whichever branch is taken**: if the wrapper is kept it keeps
the published `all`/`allOk` names (that is what narrows the break, per the task
below) and the list-shaped operation is exported beside it under its own names
(say `allList`/`allOkList`); if the wrapper is dropped, the list shape takes
the old names. Every arbitrary-length fan-out — the traversal sites in the
table, and combinators born after this issue
([allvoid-combinator](./allvoid-combinator.md),
[allreduce-combinator](./allreduce-combinator.md)) — calls the *list-shaped*
callable by whichever name this decision lands on, so those designs are
buildable under every permitted outcome.

### Alternatives considered

- **Chunk the traversal.** Fan out in groups below the limit. This puts a constant back
into the shared walk, which is the mistake
[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)
spends several pages on, and it changes the concurrency of every run to work around an
argument-passing detail. No.
- **Leave it.** Defensible today, and what this issue does for now. It stops being
defensible the first time a generated suite puts tens of thousands of leaves in one
module.

### Tasks

- [ ] Decide the list-shaped `All` signature and whether a variadic wrapper stays.
**Either way this is breaking, and the entry must say so.** Changing the
*operation* breaks every `all` handler however it is spelled at call
sites; dropping the wrapper additionally changes the published
`all`/`allOk` call shape, which reaches every fixed-arity caller
(`both`, hand-written fan-outs in proofs) and any external importer —
so the PR migrates every in-repo caller in the same change and carries
a `**BREAKING CHANGES:**` changelog entry naming what moved. Keeping
the wrapper narrows the break to the handlers, which is the argument
for keeping it.
- [ ] Move every interpreter and fixture to it in one change, and every spread site in the
table above with them. Future combinators scheduled after this issue are
consumers too, born list-shaped rather than migrated:
[allvoid-combinator](./allvoid-combinator.md) and
[allreduce-combinator](./allreduce-combinator.md) both say so in their
proposals — an arbitrary-length fan-out combinator with a spread in its
body would rebuild this ceiling inside itself.
- [ ] Prove a fan-out above the current ceiling — the number itself is engine-specific, so
the proof asserts that a large fan-out completes rather than asserting the ceiling.

### Related

- [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)
— where the browser's accidental protection was removed, and why.
55 changes: 35 additions & 20 deletions fjs/effects/todo/allreduce-combinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,49 @@ The pattern `step(all(...xs.map(f)), rs => pure(rs.reduce(op, init)))` — fan o

```ts
export const allReduce =
<O extends Operation, T, R>(
f: (item: T) => Effect<O, R>,
<O extends Operation, T, R, E>(
f: (item: T) => Effect<O, R, E>,
) =>
(op: (a: R) => (b: R) => R) =>
(init: R) =>
(items: List<T>): Effect<O | All, R> =>
step(
all(...toArray(items).map(f)),
rs => pure(rs.reduce((a, b) => op(b)(a), init)))
(items: List<T>): Effect<O | All, R, NotImplemented | E> =>
mapStep(
allOk(toArray(items).map(f)),
rs => rs.reduce((a, b) => op(b)(a), init))
```

Note the standalone `step`: `all(...)` returns a raw `Effect`, which is plain
data with no methods, so `all(...).step(...)` — as an earlier draft of this
issue wrote it — would not compile. If
[map-step-combinator](./map-step-combinator.md) lands first, the body is
`mapStep(all(...toArray(items).map(f)), rs => rs.reduce(...))`.
**Built on `allOk`, not on raw `all`, and the error channel is a parameter.**
`all`'s continuation receives `readonly Result<R, E>[]` — the children's
failures arrive *inside* the value — so a monoid folding those elements as
`R` either does not type-check or aggregates failure tuples as data. An
earlier sketch of this issue did exactly that. `allOk` collapses the list to
`readonly R[]` and lifts the first failure into the effect's error channel,
which is how every named consumer below already behaves at its existing
`allOk` call sites; `NotImplemented` is the runner's, inherited from `allOk`,
and `E` is the children's.

`op` must be **commutative** — results may arrive in any order when the runner schedules sub-effects in parallel.
**The body hands `allOk` the list, not a spread**, per
[all-argument-limit](./all-argument-limit.md)'s naming rule (`allOk` above
names the list-shaped callable — `allOk` itself if the variadic wrapper is
dropped, the list-shaped sibling if it is kept): a combinator built for
arbitrarily long lists must not become another instance of the ceiling that
issue removes. This issue therefore lands after all-argument-limit; until
then only the variadic spelling compiles.

After adding `allReduce`, `runModuleMap` in `fjs/emergent_testing/module.f.mjs` simplifies to:
Note the standalone `mapStep`: `allOk(…)` returns a raw `Effect`, which is
plain data with no methods, so `allOk(…).step(…)` — as an earlier draft of
this issue wrote it — would not compile.

```ts
return allReduce
(([k, v]: Entry<unknown>) => runModule(reporter)(k, v)(zero))
(mergeState)
(zero)
(modules)
```
`op` must be **commutative** — results may arrive in any order when the runner schedules sub-effects in parallel.

**`runModuleMap` is no longer a consumer.** An earlier draft of this issue
rewrote it with `allReduce`, and the sequential plan in
[share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md)
decides the opposite: the proof traversal runs one leaf's whole chain before
the next, deliberately, and fanning its modules back out would undo that
decision. The combinator's consumers are the sites that *want* fan-out — the
framework-registration path and `dev/module.f.mjs`'s file loading — and it
must not be applied to the traversal.
Comment thread
sergey-shandar marked this conversation as resolved.
Comment thread
sergey-shandar marked this conversation as resolved.

### Naming

Expand Down
33 changes: 27 additions & 6 deletions fjs/effects/todo/allvoid-combinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,26 @@ sites already spell it that way.
export const allVoid =
<O extends Operation, T, E>(f: (item: T) => Effect<O, void, E>) =>
(items: readonly T[]): Effect<O | All, void, NotImplemented | E> =>
mapStep(allOk(...items.map(f)), () => undefined)
mapStep(allOk(items.map(f)), () => undefined)
Comment thread
sergey-shandar marked this conversation as resolved.
```

The body hands the *list-shaped* callable the list, not a spread: `allVoid`
exists for arbitrary-length fan-outs, which is exactly where
`allOk(...items.map(f))` would rebuild the engine argument ceiling
([all-argument-limit](./all-argument-limit.md)) inside the new combinator —
the same correction [allreduce-combinator](./allreduce-combinator.md)
carries. `allOk` in the sketch names that list-shaped operation under
all-argument-limit's naming rule: it is `allOk` itself if the variadic
wrapper is dropped, and the list-shaped sibling (`allOkList` in that issue's
sketch) if the wrapper keeps the published names — either way the body's
call shape is one array argument. That makes this issue's landing depend on
the list-shaped operation from that issue; until it lands, the spread
spelling is the only one that compiles, which is one more reason this issue
is scheduled after the `All` move rather than before it.

`NotImplemented` in the error channel is the runner's, inherited from `allOk`;
`E` is the children's. Written with the standalone `step` instead —
`step(allOk(...items.map(f)), () => pureOk(undefined))` — it is the same effect
`step(allOk(items.map(f)), () => pureOk(undefined))` — it is the same effect
said less directly; either works. Note `pureOk`, not `pure`: `pure` takes a
`Result` (`pureOk = v => pure(ok(v))`), so `pure(undefined)` would yield a bare
`undefined` where the chain expects `ok(undefined)`. Both spellings must also
Expand All @@ -87,17 +101,24 @@ no host API in it.
The three call sites become `allVoid(e => registerOne(t, e))(sub)` etc.
If [allreduce-combinator](./allreduce-combinator.md) lands first, consider
deriving `allVoid` from `allReduce` with a unit monoid instead of
duplicating the `allOk(...map)` core — but only once `allReduce` is itself
built on `allOk`. As proposed it folds over `all(...)`, so its monoid receives
the children's `Result`s as ordinary values, and a unit monoid over those
would discard precisely the failures this section exists to keep.
duplicating the shared core — its proposal is now built on the list-shaped
`allOk`, so its monoid receives plain `R`s and the first failure travels the
error channel, which is exactly what a unit monoid needs. (An earlier sketch
of that issue folded over raw `all(...)`, whose monoid would have received
the children's `Result`s as ordinary values — a unit monoid over those would
discard precisely the failures this section exists to keep; that sketch is
recorded as superseded there.)

### Tasks

- [ ] Wait for [node-module-layering](./node-module-layering.md) to move
`All`/`all`/`both` **and `allOk`** to `fjs/effects/all/module.f.mjs`.
`allVoid` is built on `allOk`, so moving one without the other inverts
the layering.
- [ ] Wait for [all-argument-limit](./all-argument-limit.md)'s list-shaped
`allOk`, and hand it the list: `allVoid` is an arbitrary-length
fan-out, so a spread in its body would rebuild the argument ceiling it
is called at (the note under the proposal).
- [ ] Add `allVoid` there (next to `all`/`both`) with proof coverage — **not**
to `fjs/effects/node/module.f.mjs`, per the note at the top of this issue.
- [ ] Convert the three `mapStep(allOk(...), () => undefined)` call sites in
Expand Down
Loading
Loading