Skip to content

common/monoid: make fold a balanced reduction - #1548

Merged
sergey-shandar merged 6 commits into
mainfrom
claude/epic-fermi-u09hzj
Aug 14, 2026
Merged

common/monoid: make fold a balanced reduction#1548
sergey-shandar merged 6 commits into
mainfrom
claude/epic-fermi-u09hzj

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

Implements fjs/common/monoid/todo/balanced-fold.md (deleted here), following its design and its Decisions section.

What changed

fold derived from list reduce, so [a, b, c, d] evaluated as ((a op b) op c) op d — the maximally unbalanced tree. For size-growing exact operations that is O(n²): the accumulator grows while every new operand stays small, so step k costs work proportional to k.

A Monoid promises associativity, which is precisely the license to re-parenthesize, so fold now reduces as a balanced binary tree — merging runs of equal size, the binary-counter algorithm that bit_vec.unpackListToVec already implemented for one concrete operation. The split falls out cleanly and is stated in the JSDoc:

  • list.reduce takes an arbitrary operation with no associativity contract → stays strictly left-to-right;
  • monoid.fold takes a Monoid → free to balance.

It is also the list-shaped sibling of repeat, which already exploits associativity for log-depth work via exponentiation by squaring.

Only the grouping changes, never the order — each merge keeps the earlier run on the left — so non-commutative monoids (string/bit_vec concat) stay correct.

Implementation: an immutable linked stack of { size, value, rest } runs. push merges while the top run has the same size (the carry of incrementing a binary counter, so the stack holds at most log2(n) runs), and combine folds the leftover runs earliest-first, seeded at identity. The list walk itself is list.fold, so laziness is unchanged.

bit_vec drops its private copy

tryListToVec is now the generic fold over a Nullable<Unpacked> monoid whose null is an absorbing "longer than maxLength" element, exactly as the issue designed it. The Accumulator / _ListToVecState bookkeeping and the hand-rolled slot array are deleted.

That monoid is lawful: length is additive and non-negative, so a partial combine can only overflow when the total does, and the top-level combine always sees the full total — the result is null iff the total exceeds maxLength, independent of grouping.

Two details worth flagging:

  • The cap is checked on the operands' lengths, not on the concatenated result, because the oversized result is the thing that must not be built (maxLength is the smallest bigint size across the supported runtimes). These lengths are exact and additive, not an estimate, so this is not the precomputed-size prediction AGENTS.md §5.6 rules out.
  • As the issue accepts, overflow is no longer short-circuited: null propagates to the end of the walk instead of abandoning the list. Reusing one generic fold is worth finishing a walk over an already-doomed list.

unpackListToVec(unpackConcat) is now bound once per bit order rather than rebuilt per call (fold does its setup at application time). That was one bullet of fjs/types/bit_vec/todo/front-from-unpack-split.md; its task list is updated, the rest of that issue is untouched.

Measurements

n = 20_000, Node 22, this tree before vs. after:

reduction left fold balanced
bigint.product 114.2 ms 29.5 ms
msb.listToVec 28.7 ms 27.9 ms (already binary-counter)
string.concat 16.3 ms 20.3 ms

string.concat is the one that gets slower, not faster: V8 builds ropes, so left-folded string concatenation is already O(1) per step and the balancing only adds run bookkeeping. It stays on the uniform fold per the issue's decision — one combinator, no fold/foldBalanced split — and the cost is a fraction of a millisecond per 10k elements.

number.sum re-groups its inexact addition, so its result can differ in the last bits from a left fold (O(log n · ε) instead of O(n · ε) accumulated error). Better on average, but incidental rather than a goal — no proof asserted a value that moved.

Checks

  • npx tsc — clean.
  • npm test — 2647 pass, 0 fail.
  • npm run covcommon/monoid/module.f.mjs and types/bit_vec/module.f.mjs at 100% lines / branches / functions; repo totals unchanged from before the change (99.96 / 98.62 / 99.80).
  • No Rust touched.

New proof coverage: monoid.proof.fold.balanced pins the grouping with a deliberately non-associative bracketing operation (a lawful monoid could not observe grouping at all — that is the point), and bit_vec.proof.tryListToVecOverflow gains a three-element case so the absorbing null is exercised on both sides of a combine.

CHANGELOG entry follows in a commit on this branch, per AGENTS.md §8.3.


Generated by Claude Code

`fold` derived from list `reduce`, so it evaluated a maximally unbalanced
left-associated tree. For size-growing exact operations (`bigint.product`,
`string`/`bit_vec` concatenation) that costs O(n²): the accumulator grows
while every new operand stays small. A `Monoid` promises associativity,
which is exactly the license to re-parenthesize, so `fold` now merges runs
of equal size — the binary-counter algorithm `bit_vec.unpackListToVec`
already had — for O(n log n). `list.reduce` takes an arbitrary operation
with no associativity contract and stays strictly left-to-right.

Only the grouping changes, never the order, so non-commutative monoids
stay correct. `number.sum` moves the rounding of its inexact addition
(O(log n · ε) instead of O(n · ε)); better on average, but incidental.

`bit_vec` then drops its own copy of the algorithm: `tryListToVec` becomes
the generic `fold` over a `Nullable<Unpacked>` monoid whose `null` is an
absorbing "longer than `maxLength`" element, and the `Accumulator` /
`_ListToVecState` bookkeeping goes away. Overflow is no longer
short-circuited — `null` propagates to the end of the walk instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v8zmnR1ruXSVbd94kvo48
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript 5d7d026 Commit Preview URL

Branch Preview URL
Aug 14 2026, 01:16 PM

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v8zmnR1ruXSVbd94kvo48

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at fcfe1345, baseline origin/main = 725129fe ("Remove unreachable throw in djs serializer's constSerialize (#1544)").

The core of this is right, and I checked the association-order question directly rather than taking the Monoid contract on faith. Three things need attention, one of them behavioural.

What I verified

Every Monoid instance in the repo, tested for associativity rather than assumed. Six instances reach fold: bigint.sum, bigint.product, number.sum, string.concat, and bit_vec's tryUnpackConcat for each of lsb/msb. (Three more reach repeat, which this PR does not touch: prime_field pow, secp mul, bit_vec.repeat.) 2000 random triples per instance:

  • bigint.sum, bigint.product, string.concat — 0/2000 non-associative, identity law holds both sides.
  • number.sum281/2000 non-associative, as expected for IEEE-754.

Old vs new fold swept over lengths 0–40 plus 63/64/65, 127/128/129, 255/256/257 (straddling the binary-counter merge points):

  • bigint.sum, bigint.product, string.concat — identical at every length.
  • number.sum — differs at 30 of the 50 lengths, first at n=5 (8044369.416131745 vs ...744).

Negative-controlled with the bracketing monoid: n=4 old (((ab)c)d) vs new ((ab)(cd)), n=8 old fully-left vs new (((ab)(cd))((ef)(gh))), so the comparison does detect regrouping.

On number.sum: neither answer is "correct" — the exact sum is representable by neither. The new grouping has the better error bound (O(log n·ε) vs O(n·ε)), so this is an improvement, not a regression, and it is disclosed. Not asking for a **BREAKING CHANGES:** prefix: on the repo's precedent that marks specifier/API breaks (#1520), not a last-ulp numerical shift (#1524 was representation-only and unprefixed). Flagging it only so the judgement is on the record.

bit_vec: 280 old-vs-new comparisons — tryListToVec and tryU8ListToVec, both bit orders, all the lengths above, plus an oversized element at every position for L=2..9, plus totals of exactly maxLength-1/maxLength/maxLength+1. 0 differences. The absorbing-null argument holds: lengths are additive and non-negative, so no partial combine overflows unless the total does, and the final combine always sees the full total.

Battery: npx tsc --noEmit 0 · npm run prepack 0 in both trees · npm test 2647 pass / 0 fail vs 2646 on main (+1 = the new balanced case; the bit_vec overflow case was extended in place) · link-check broken sets identical, 154 = 154, empty symmetric difference, and no stranded reference to the deleted todo/balanced-fold.md · public const surface: nothing added or removed, only fold's parameter name ({ identity, operation }monoid), same type · type surface: _ListToVecOp/_ListToVecState removed, _Stack added — correctly _-prefixed, no §6.2 issue · @module survives into both emitted declarations, no elided/any.

§3.2 mutation testing of the new proofs, not just their names. monoid.proof.fold.balanced kills: never merging (stack.size !== size → always push) and reversing combine's run order. bit_vec.proof.tryListToVecOverflow kills dropping the maxLength cap. Correcting my own error: my first attempt at "swap the merge operands" appeared to survive, but the replacement had landed on the JSDoc at line 81 that quotes operation(stack.value)(value) rather than the code at line 89. Applied to line 89 it is killed ((ba) vs (ab)). The proof is sound; my mutation was not.


1. tryListToVec / tryU8ListToVec no longer terminate on an unbounded lazy list

List<T> includes Thunk<T> = () => List<T>, and tryFold exists precisely so a fold can bail out. Dropping it costs more than an extra walk. On an infinite lazy list whose prefix already overflows:

  • main: returns null in 2 ms, pulling 0 elements past the overflow.
  • this PR: still pulling at 200,000 elements past the overflow when I cut it off. It never terminates.

The PR describes this as "an already-doomed list is still walked to completion" and treats it as a bounded cost. For a finite list that is accurate. For a lazy unbounded one it is non-termination, which is a different statement — and maxLength is only 0x100000 (1 Mbit = 128 KiB), so overflow is an ordinary outcome, not an exotic one: any byte stream over 128 KiB through tryU8ListToVec reaches it. A try-prefixed function returning Nullable<Vec> is exactly what you would reach for when handed an untrusted or generated stream, which is the case that now hangs.

I do not think this sinks the design — reusing one generic fold is a good trade. But the JSDoc on tryUnpackConcat should say that unbounded lazy input no longer terminates, rather than implying the cost is just a completed walk.

2. The CHANGELOG names only common/monoid, but types/bit_vec changed observably

The entry covers fold alone. fjs/types/bit_vec/module.f.mjs is rewritten (−54/+46) and its public tryListToVec/tryU8ListToVec changed behaviour in the way above. §8.3 wants an entry for the code change, and the termination change in particular is the kind of thing a reader of the CHANGELOG needs. This is the same shape as #1540 and #1542, where the entry named one function and a second had moved too.

3. The CHANGELOG's complexity claim is wrong for two of the three cases it names

taking bigint.product and string/bit_vec concatenation from O(n²) to O(n log n)

Measured here, n=20,000, main vs this branch:

main PR
bigint.product 39.2 ms 9.4 ms
string.concat 5.2 ms 6.3 ms
msb.tryListToVec 13.0 ms 12.4 ms
  • bigint.product — claim holds, ~4x, and plausible against the code given V8's subquadratic multiplication.
  • string.concatnot O(n²) to begin with: V8 builds ropes, so left-folded concatenation is already ~O(1) per step. This PR makes it measurably slower, not faster. The PR description says exactly this; the CHANGELOG says the opposite.
  • bit_vec concatenation — was already O(n log n) on main. The deleted listToVecOp was a binary-counter accumulator and its own JSDoc said "O(n log n) total bigint shifting work instead of the O(n²) of a naive left fold". It is unchanged, not improved.

The honest entry is that bigint.product improves, bit_vec is refactored onto the shared combinator at equal cost, and string.concat pays a small price for uniformity.


Everything else checks out, and the balanced proof using a deliberately non-associative bracketing operation to pin the grouping is the right way to test this — a lawful monoid could not observe it. Happy to approve once the CHANGELOG is accurate and the non-termination is documented.

Reproduced the non-termination against `origin/main` (725129f): an
unbounded lazy list whose prefix overflows returns `null` after 131_073
pulls there, and keeps pulling past 50_000 elements beyond the overflow
here. `tryUnpackConcat`'s JSDoc said the cost was a completed walk, which
is only true of a finite list — it now states the unbounded-lazy case.

File `types/bit_vec/todo/listtovec-overflow-early-exit.md` for restoring
the early exit generically: an `Absorbing<T>` companion to `Monoid<T>`
plus a `foldAbsorbing` built on the existing `push`/`combine` helpers and
`list.tryFold`, rather than hand-driving the walk in `bit_vec` again.

The CHANGELOG claimed O(n²) → O(n log n) for `string` and `bit_vec`
concatenation too. Neither is right: V8 ropes make left-folded string
concatenation ~O(1) per step (it gets slightly slower here), and
`bit_vec` was already a binary-counter accumulator (same cost, now
shared). Split into per-module entries and stated honestly, with the
`bit_vec` termination change named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v8zmnR1ruXSVbd94kvo48

Copy link
Copy Markdown
Contributor Author

All three addressed in 8ece666. Thanks for the associativity sweep and the mutation testing — the lazy-list case in particular is one my finite-list testing could not have found.

1. Non-termination — reproduced, then documented. I re-ran your experiment against origin/main = 725129f (in a separate worktree; my first attempt used git stash, which does nothing once the work is committed, so it silently compared the branch against itself):

pulls before answering result
main 131 073 — exactly the overflow point null in 76 ms
this PR still pulling at 50 000 past overflow never returns

You are right that "an already-doomed list is still walked to completion" is a statement about finite lists only, and that on List<T>, which includes Thunk<T>, it is non-termination rather than a bounded cost. tryUnpackConcat's JSDoc now separates the two cases and says plainly that tryListToVec/tryU8ListToVec never return on unbounded lazy input.

I did not fix it here — that is a new combinator in another module, not this PR's one change. Filed as fjs/types/bit_vec/todo/listtovec-overflow-early-exit.md with a concrete design: an Absorbing<T> companion to Monoid<T> (composition, not intersection — Monoid stays untouched) plus a foldAbsorbing built on this PR's existing push/combine helpers with list.tryFold driving the walk, so the balanced grouping and the log2(n) stack bound stay as they are and only the walk gains an exit. Putting it back in bit_vec would restore the duplication this PR removed, so the fix belongs in common/monoid.

2 & 3. CHANGELOG. Both correct, and 3 was my error: the entry claimed the improvement for all three reductions while the PR description said the opposite for two of them. Re-measured against the same 725129f worktree, n=20 000:

main PR
bigint.product 117.9 ms 29.7 ms
string.concat 15.9 ms 19.2 ms
msb.listToVec 37.6 ms 26.8 ms

Same directions as yours. string.concat gets slower, and bit_vec was already a binary-counter accumulator — its own deleted JSDoc said so — so it is refactored onto the shared combinator at equal cost, not improved. Now two entries: one for common/monoid stating the bigint.product gain, the string.concat cost, and the number.sum rounding; one for types/bit_vec naming the reuse and the termination change.

Agreed on no **BREAKING CHANGES:** prefix for the number.sum last-ulp shift, on the precedent you cite.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 8ece6666 (previously commented at fcfe1345). Baseline origin/main = 725129fe.

The delta since the last round is documentation only — the JSDoc on tryUnpackConcat, a new todo/listtovec-overflow-early-exit.md, and a rewritten CHANGELOG. Two of the three open items are genuinely closed; the third is now accurately described rather than fixed, and I think it still needs one change before merge.

Resolved: CHANGELOG now names types/bit_vec, and the characterisation checks out

The Unreleased section carries a separate types/bit_vec entry, and every claim in it holds when measured. Same shape as the fix applied to #1540 and #1542.

Resolved: the complexity claim now matches measurement

The old wording claimed string/bit_vec concatenation went from O(n²) to O(n log n), which was wrong on both. I re-measured rather than accepting the rewrite — main @ 725129fe vs this head, n = 20 000, best of 3 after a warm-up:

main PR new CHANGELOG says
bigint.product 33.6 ms 3.8 ms "drops from O(n²) to O(n log n)" — holds
string.concat 2.4 ms 3.2 ms "pays a little for the one combinator" — holds (V8 ropes mean the left fold was never quadratic)
msb.tryListToVec 5.4 ms 5.2 ms "at the same cost as the accumulator they replace" — holds (main was already a binary counter, so already O(n log n))
bigint.sum 1.9 ms 2.2 ms

Three accurate statements where there were previously two wrong ones.

Still open: tryListToVec / tryU8ListToVec do not terminate on an unbounded lazy list

I re-ran the experiment at this head rather than taking the disclosure's word for it. An unbounded Thunk list of 32-bit vectors, so the prefix crosses maxLength (0x100000 bits = 128 KiB) after 32 768 elements:

  • main 725129fe: msb.tryListToVec(inf) returns null after 32 769 pulls in 14 ms. Bounded, terminating.
  • this head 8ece6666: still pulling at 54 731 606 elements after 15 s, at which point I aborted it. It is a tight synchronous loop — the process does not even service a setInterval. It never returns.

That is 1670× more elements than main pulls, and growing without bound. The documentation is now honest about this: the JSDoc says "keep pulling elements forever and never return", the CHANGELOG says "an unbounded lazy list past maxLength never returns", and the new todo/ file reproduces my earlier numbers and proposes the right fix (an absorbing element on Monoid so the shared fold can stop, rather than hand-rolling the walk back in bit_vec). I agree with that design — putting the early exit in the combinator is better than reverting bit_vec to its own accumulator.

But disclosure is not the same as a fix, and I do not think this one should ship deferred at P3:

  • Termination → non-termination is a different category from a slowdown. Every other cost in this PR is a constant factor; this one is unbounded.
  • It lands on exactly the API a caller reaches for when the input is untrusted. A try-prefixed function returning Nullable<Vec> advertises "hand me anything, I will tell you if it does not fit" — and maxLength is only 128 KiB, so overflow is an ordinary outcome, not an exotic one. The todo file makes both of these points itself.
  • "Hand them a finite list" is not a precondition the type expresses: List<T> includes Thunk<T>, and nothing at the boundary distinguishes the finite case.

Two ways out, either fine by me:

  1. Implement the absorbing proposal in this PR. It is small — an optional field on Monoid, one === absorbing check in push, and bit_vec sets it to null.
  2. If it stays deferred, the CHANGELOG entry needs the **BREAKING CHANGES:** prefix (AGENTS.md §8.4). Published exports on both lsb and msbtryListToVec, listToVec, tryU8ListToVec, u8ListToVec — change from returning to hanging on an input class they previously handled. This series has used the prefix for considerably less (fjs/ci/deno no longer exports coverageInclude). It is not the same case as number.sum's rounding drift, which I agree stays unprefixed on the #1520-vs-#1524 precedent: that is a value moving within its documented contract, not a call that stops returning.

Verified clean

  • npx tsc --noEmit exit 0; npm run prepack exit 0 from a freshly cleaned tree (both passes).

  • npm test: 2647 pass / 0 fail, against 2646 on 725129fe. The +1 is the new fold.balanced case.

  • Public surface, both axes, after prepack in each tree: fold's signature goes from ({ identity, operation }: Monoid<T>) to (monoid: Monoid<T>) — same type, nothing widened to any. _ListToVecOp and _ListToVecState drop, _Stack<T> is added; all three are _-prefixed per §6.2, so no unprefixed additions. @module survives emit in both changed modules, no elided/any in the declarations.

  • Broken-link sets identical to main, 154 entries either side — the todo/balanced-fold.md deletion strands nothing.

  • §3.2, mutation-tested rather than read off the case names. Four mutations to module.f.mjs, each confirmed to have landed on code (not on a JSDoc line quoting the expression, which is how one appeared to survive last round), each run against the co-located proof in isolation:

    • swap the merge order in pushfold.order and fold.balanced both fail
    • never merge (stack.size !== sizetrue) → fold.balanced fails with (((ab)c)d)
    • swap the operand order in combinefold.order and fold.balanced both fail
    • size * 2sizefold.balanced fails

    All four caught. The bracket monoid in fold.balanced is a good way to make grouping observable, and the comment explaining why a deliberately non-associative operation is legitimate there is exactly right.

  • bit_vec's three-element overflow addition: dropping either null guard in tryUnpackConcat, or making the identity null, is caught by the co-located proof. Widening the guard to >= survives — but it survives on main too, so that is a pre-existing boundary gap, not something this PR introduces.

The association-order analysis from the last round still stands and I did not redo it; nothing in this delta touches the fold.

…lement

Reviewer's option 1 rather than a `**BREAKING CHANGES:**` disclosure —
non-termination is not something to ship documented when the fix belongs
in the combinator anyway.

`Absorbing<T>` carries a `Monoid<T>` plus its absorbing element (a value
that swallows the operation from either side, so a fold that reaches it
already knows the answer). `foldAbsorbing` is `fold` with that fact used:
the same `push`/`combine` helpers, but `list.tryFold` drives the walk and
stops as soon as a run reaches the absorbing element. Composition, not an
optional field, so `Monoid` and `fold` are untouched.

`bit_vec`'s capped concatenation supplies `null` as its absorbing element,
so `tryListToVec`/`tryU8ListToVec` terminate on unbounded lazy lists again:
`null` after 262_144 pulls where the cap is crossed at 131_073, against
main's 131_073 and this branch's previous never. A single vector is never
`null`, so only a merge can reach the absorbing element and the stop lags
the crossing by at most one doubling of the run size — bounded, which is
the property the type cannot otherwise express.

Deletes the todo filed for this last round, and drops the CHANGELOG's
non-termination warning since there is nothing left to warn about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v8zmnR1ruXSVbd94kvo48

Copy link
Copy Markdown
Contributor Author

Took option 1 — implemented in ada2e03. You're right that termination → non-termination is a different category from a constant factor, and it is not something to ship documented when the fix belongs in the combinator anyway.

foldAbsorbing

Absorbing<T> in common/monoid/types.ts carries a Monoid<T> plus its absorbing element (operation(absorbing)(a) = operation(a)(absorbing) = absorbing), and foldAbsorbing is fold with that fact used: the same push/combine helpers and the same balanced grouping, but list.tryFold drives the walk and update returns the stop signal as soon as a run reaches absorbing.

I made it composition rather than the optional field you suggested — Monoid and fold are untouched, and bit_vec's T is Nullable<Unpacked> with absorbing: null, so an optional field would have had to lean on the undefined-vs-null distinction the codebase normalizes away. push's return type is now _Run<T> rather than _Stack<T>, which is what lets null be unambiguously tryFold's stop signal and never a state — the invariant is in the type instead of a comment.

bit_vec supplies null as the absorbing element and tryListToVec/tryU8ListToVec terminate again. Re-running your experiment at ada2e03:

pulls result
main 725129fe 131 073 null in 76 ms
8ece6666 aborted at 50 000 past the crossing never returns
ada2e03 262 144 null in 83 ms

One honest caveat, documented rather than papered over

A single vector is never null, so only a merge can reach the absorbing element — and runs merge at power-of-two boundaries, so the stop can lag the element that actually crossed the cap by up to one doubling of the run size. Measured across element sizes: 65 536 pulls for a crossing at 32 769 (2.00×), 2 048 for 1 025 (2.00×), 4 for 3 (1.33×). Bounded by a constant factor, never unbounded, which is the property List<T> cannot express and the one that matters here. Stated in both JSDocs with the numbers.

Where an element is absorbing on its own — 0 in a product — it stops at that element with no lag, and the proof covers both shapes.

New proofs

  • monoid.proof.foldAbsorbing.stopsEarly — an unbounded lazy list whose elements past the absorbing one call assert(false) if they are ever read. Covers both the absorbing-element and absorbing-merge cases.
  • bit_vec.proof.tryListToVecUnbounded — the same shape at the real cap: maxLength / 2 per element, so the total crosses at the third, with the tail asserting it is not read past the eighth.

Checks

npx tsc clean · npm run prepack exit 0, foldAbsorbing emits as <T>({ monoid, absorbing }: Absorbing<T>) => (list: List<T>) => T with no any/elided, and the added _Run/_Stack are both _-prefixed · npm test 2651 pass / 0 fail · npm run cov both modules still 100% lines/branches/functions, repo totals unchanged at 99.96 / 98.62 / 99.80.

todo/listtovec-overflow-early-exit.md is deleted — fixed in the PR that filed it — and the CHANGELOG's non-termination sentence is gone since there is nothing left to warn about; the common/monoid entry now mentions foldAbsorbing. No **BREAKING CHANGES:** prefix, since no input class changes behaviour any more.

Thanks for pushing on this one rather than accepting the disclosure.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at ada2e039 (previously commented at 8ece6666). Baseline origin/main = 725129fe.

The blocking finding is fixed. foldAbsorbing restores the early exit, and I re-ran the exact experiment.

Non-termination — resolved

Unbounded lazy List of 32-bit vectors (cap crossed at element 32 769), counting pulls:

main 725129fe PR ada2e039
lsb.tryListToVec 32 769 pulls, 17 ms, null 65 536 pulls, 27 ms, null
msb.tryListToVec 32 769 pulls, 15 ms, null 65 536 pulls, 24 ms, null
lsb.listToVec 32 769 pulls, throws 65 536 pulls, throws
msb.listToVec 32 769 pulls, throws 65 536 pulls, throws
tryU8ListToVec(lsb/msb) 131 073 pulls, ~30 ms, null 262 144 pulls, ~43 ms, null
u8ListToVec(lsb/msb) 131 073 pulls, throws 262 144 pulls, throws

All four affected exports on both orders now return, with the same result as main, in under 2x the pulls. At 8ece6666 the same probe was still pulling at 54.7M elements after 15 s and never returned.

The two numbers the foldAbsorbing JSDoc cites reproduce exactly: 65 536 elements read for a cap crossed at 32 769, and 4 read for a cap crossed at 3 (maxLength/2-sized elements — main reads 3). The "at most one doubling" bound holds in every case I measured.

Results unchanged for non-overflowing input

380 comparisons of PR output against main, all identical:

  • element widths 0–5 bits x lengths 0–40, both bit orders;
  • mixed-width elements, lengths 0–30;
  • maxLength boundary: 60–68 elements of maxLength/64; and [vec(maxLength-1+d), vec(1)] for d in -2..2, i.e. exactly at, just under and just over the cap;
  • cap crossed at every run-boundary position: 1–40 elements of maxLength/16;
  • tryU8ListToVec lengths 0–40 and at maxLength/8 -2..+2 bytes;
  • the throwing u8ListToVec wrapper at and past the cap.

Cost claim re-derived at n=20k, non-overflowing (best of 4 after warmup): tryListToVec 6.3 ms on main vs 5.6 ms here; tryU8ListToVec 4.4 vs 3.8. "At the same cost as the accumulator they replace" measures true.

Rest of the battery

  • npx tsc --noEmit 0 on both trees.
  • npm run prepack 0 from a freshly cleaned tree, both trees.
  • npm test: 2651 pass / 0 fail here vs 2646 / 0 on main — +5, matching the new foldAbsorbing proofs and tryListToVecUnbounded.
  • Public surface (dual-axis, from emitted declarations): exactly two const changes — foldAbsorbing added as <T>({ monoid, absorbing }: Absorbing<T>) => (list: List<T>) => T, and fold's parameter rendered as monoid: Monoid<T> instead of the destructured form (same type). Types: +Absorbing, +_Run, -_ListToVecOp, -_ListToVecState. No signature widened to any. _Run correctly _-prefixed; Absorbing is genuinely public since it is in foldAbsorbing's signature.
  • @module survives emit in both module.f.d.mts and types.d.ts; no elided or any in the monoid declarations.
  • Broken-link sets identical to main — deleting types/bit_vec/todo/listtovec-overflow-early-exit.md stranded nothing.

Mutation tests on the new code, each verified to have landed on the expression and not a JSDoc line:

  • next.value === absorbingfalse (never stop): 2 failures. Killed.
  • result === null ? absorbing : resultresult: killed by tsc (Nullable<T> not assignable to T).
  • absorbing: nullabsorbing: unpackEmpty in tryUnpackConcat: 18 failures. Killed.
  • push(monoid.operation)(1)(2) in absorbingAccumulator: survived, but this is an equivalent mutant — run sizes are only ever compared for equality, so seeding at 2 produces the identical merge tree. Not a coverage gap.

Two nits, neither blocking

  1. foldAbsorbing reads tryFold's null as "stopped", but tryFold also returns end(state) unchanged, so a monoid whose T includes null with a non-null absorbing element would get absorbing where fold returns null. Unreachable in this repo — bit_vec is the only user and its absorbing element is null, so the two agree — and my sweep confirms it. Worth a line in the JSDoc if foldAbsorbing ever grows a second caller.

  2. absorbingAccumulator builds push(monoid.operation)(1) itself where the file already has step = operation => push(operation)(1) one screen up. const p = step(monoid.operation) would say the same thing and keep the seed in one place.

Approving.

Nit 1, fixed rather than documented. `tryFold` reports "stopped" and
"finished with this result" through the same `Nullable<R>`, so a monoid
whose `T` includes `null` would have had a completed fold that produced
`null` read as an abandoned walk. `end` now returns a one-element tuple,
which separates the two exactly — one allocation per fold, none per
element. `foldAbsorbing` and `fold` now agree on every finite list.

Proof: `min` over non-negative numbers with `null` as the identity
(+infinity) and `0` as the absorbing element. Folding `[]` must give
`null`, not `0`; reverting `end` to the bare value fails it.

Nit 2: `absorbingAccumulator` takes the seeded step from `step` instead
of repeating `push(operation)(1)`. `fold` needs the same function with
its result widened to the stack type `list.fold` threads, so that view is
a second named binding — assignable, not a second definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v8zmnR1ruXSVbd94kvo48

Copy link
Copy Markdown
Contributor Author

Thanks for the approval. Both nits are addressed in 6fafb91 — the first fixed rather than documented, since a latent trap in a newly-exported generic is worth an allocation to remove.

1. The tryFold signal is now unambiguous. end returns a one-element tuple, so "stopped" (null) and "finished with this result" ([value]) are distinct at the type level rather than by convention. That costs one allocation per fold, none per element, and makes foldAbsorbing(a)(list) and fold(a.monoid)(list) agree on every finite list — including one whose folded value is itself null, which is exactly the case you identified. The JSDoc now states that agreement as the contract instead of warning a future caller about the gap.

The proof is a lawful instance of the shape you described: min over non-negative numbers, with null as the identity (+infinity) and 0 as the absorbing element — so T includes null without null being the stop condition. foldAbsorbing(min)([]) must be null, not 0. I mutation-tested it: reverting end to the bare value fails nullValued and nothing else, so the case is load-bearing rather than decorative.

2. Seed shared. absorbingAccumulator takes step(monoid.operation). fold needs the same function with its result widened from _Run<T> to the _Stack<T> that list.fold threads — inference picks the return type, so the widening has to be named — and that view is foldStep, a binding assigned from step rather than a second definition. The 1 appears once.

Your equivalent-mutant analysis of push(operation)(1)(2) is right, and it survives here too for the same reason: sizes are only ever compared for equality, so any seed produces the same merge tree. Only the doubling relationship matters, which the size * 2 mutant covers.

Re-verified at 6fafb91: npx tsc clean · npm run prepack exit 0 · npm test 2652 pass / 0 fail (+1 for nullValued) · npm run cov both modules 100% lines/branches/functions, repo totals unchanged at 99.96 / 98.62 / 99.80. foldAbsorbing's emitted signature is unchanged — the tuple is internal to the accumulator.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 6fafb913, baseline origin/main = 725129fe (unchanged). Previously approved at ada2e039; the delta is one commit, "Review nits: make the tryFold signal unambiguous, share the run seed", touching fjs/common/monoid/module.f.mjs (+37/-10) and proof.f.mjs (+21). Both non-blocking nits from the last round are addressed, and both fixes are correct rather than merely responsive. Approving again.

Because monoid moved, I re-ran the non-termination experiment from scratch rather than carrying the previous result forward.

Non-termination: re-verified, not carried forward

Unbounded lazy list whose prefix overflows maxLength, all four exports on both bit orders, with a pull counter that throws past 20M pulls:

export PR 6fafb913 main 725129fe
tryListToVec lsb/msb 65,536 pulls / 21.7, 19.4 ms → null 32,769 / 13.7, 11.7 ms → null
listToVec lsb/msb 65,536 / 16.9, 15.2 ms → throws 32,769 / 10.7, 9.4 ms → throws
tryU8ListToVec lsb/msb 262,144 / 42.3, 40.3 ms → null 131,073 / 28.1, 27.5 ms → null
u8ListToVec lsb/msb 262,144 / 41.5, 39.7 ms → throws 131,073 / 25.6, 27.0 ms → throws

Same results as main everywhere, under 2× the pulls, matching the ada2e039 numbers exactly. Negative control: the same probe against the earlier broken head 8ece6666 hits the 20M cap without returning (~4.8 s per case), so the probe does detect the regression it is meant to detect.

Nit 1 — tryFold's stop-null vs a legitimate null result

Fixed properly, by making the signal unambiguous at the type level rather than by documenting the hazard away: the accumulator's end now yields readonly[T], so tryFold returning null can only mean "stopped", and foldAbsorbing destructures the tuple. One allocation per fold, not per element, as the comment says.

The new nullValued proof is a real test, not decoration. A partial revert (end: c alone) is killed by tsc (TS2322 at both sites). A type-coherent full revert to the ada2e039 shape — third type param back to T, end: c, return result === null ? absorbing : result — compiles and then fails at runtime: proof.foldAbsorbing.nullValued(): error, 2651 pass / 1 fail. The killing case is foldAbsorbing(min)([]), where the old code folded to the null identity and misread it as an abandoned walk, returning the absorbing 0.

I also re-derived the new JSDoc claim that foldAbsorbing(a)(list) and fold(a.monoid)(list) agree on every finite list including null-valued ones: 3,000 randomized lists over the min monoid (lengths 0–40, ~20% null elements, ~10% absorbing 0), 0 mismatches under Object.is.

Nit 2 — absorbingAccumulator re-deriving push(op)(1)

Also fixed correctly, and the type split it required is load-bearing rather than cosmetic. step is now typed as returning _Run<T> (non-nullable) so absorbingAccumulator can read next.value, and foldStep = step is the checked widening to Fold<T, _Stack<T>> that list.fold takes. Retyping step's result back to _Stack<T> produces TS18047: 'next' is possibly 'null', so the narrower type is genuinely carrying the "only the newest run can be absorbing" invariant into the checker.

Gates

  • npx tsc --noEmit — exit 0.
  • npm run prepack from a clean tree — exit 0.
  • npm test — 2652 pass / 0 fail, vs 2646 on main. +6, of which the new nullValued proof is one.
  • Public surface unchanged: foldAbsorbing: <T>({ monoid, absorbing }: Absorbing<T>) => (list: List<T>) => T, no elided or any in the emitted declarations. The readonly[T] stays internal.
  • Link check — broken-link set byte-identical to main.
  • CHANGELOG untouched by this delta, correctly: the entries for common/monoid and types/bit_vec already cover the released behaviour, and nothing here changes it.

Everything settled at ada2e039 and untouched by this delta (the 380 PR-vs-main behaviour comparisons, the cost measurements, association order across the six Monoid instances, the earlier mutation battery) I did not redo.

No findings. LGTM.

Conflicts were all with #1545's `@import` sweep:

- `types/bit_vec/module.f.mjs` — took main's single leading `@import`
  block and applied this branch's changes to it: `Accumulator` dropped
  (the accumulator is gone), `Absorbing` added, and `compose`,
  `foldAbsorbing`, `nullableMap` in the runtime imports.
- `common/monoid/proof.f.mjs` — same block, keeping this branch's `List`,
  `Nullable`, and `Absorbing` alongside `Monoid`.
- `CHANGELOG.md` — both sides added to `## Unreleased`; #1548's entries
  stay on top of the merged #1545 one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018v8zmnR1ruXSVbd94kvo48
@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit 2a1ee02 Aug 14, 2026
19 checks passed

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after the merge of origin/main (e2199caf). Still approving.

What actually moved since 6fafb913: one merge commit. fjs/common/monoid/ is
byte-identical (git diff 6fafb913..5d7d0260a367ef558ecee722993129ece28c1f6f -- fjs/common/monoid is empty). The only
change reaching this PR's files is #1545's @import sweep relocating the tags in
types/bit_vec/module.f.mjs and proof.f.mjs into the leading JSDoc block — comment
placement, no executable statement touched. Merge-base is now origin/main exactly.

Because types/bit_vec moved at all, I re-ran the load-bearing non-termination
experiment from scratch at this head rather than carrying the numbers forward,
negative control included.

Non-termination probe — unbounded lazy Thunk list whose prefix overflows
maxLength (1_048_576), counting pulls, 20M cap:

tree pulls (all 8 exports) result
this PR 5d7d0260a367ef558ecee722993129ece28c1f6f 262,144 (41–65 ms) returns
origin/main e2199caf 131,073 (28–97 ms) returns
old broken head 8ece6666 20,000,001 — cap hit, never returns

All eight — tryListToVec/listToVec/tryU8ListToVec/u8ListToVec on both lsb
and msb — terminate with results identical to main (null from the try* pair,
throw from the mapUnwrap pair), at 1.99998× main's pulls. The negative control
confirms the probe can still detect the regression: the same script on 8ece6666
hits the cap on all eight. foldAbsorbing's early exit is intact.

Rest of the battery at this head:

  • npx tsc --noEmit — exit 0.
  • npm run prepack from a cleaned tree — exit 0 (both passes).
  • npm test2652 pass / 0 fail, vs 2646 on origin/main; the +6 are the new
    common/monoid and bit_vec proofs.
  • Public surface (dual-axis, both trees prepacked) — unchanged from what was settled
    last round: +Absorbing<T>, +_Run<T>, +foldAbsorbing, -_ListToVecOp,
    -_ListToVecState, and fold's emitted parameter renamed from the destructuring
    pattern to monoid (same type). No signature widened to any.
    Absorbing being unprefixed is correct, not a §6.2 miss: it is the parameter type
    of the public foldAbsorbing, so it has to be public — the _ prefix is for
    implementation-only types, and _Run correctly carries one.
  • @module survives declaration emit in bit_vec/module.f.d.mts,
    monoid/module.f.d.mts and monoid/types.d.ts (1 each) — the @import relocation
    did not fold the header into import trivia. No elided or any in either.
  • Link check — broken-link set is identical to main's, not merely the same count.
  • CHANGELOG — the two Unreleased entries link only /pull/1548. No
    **BREAKING CHANGES:** prefix, which I still read as right: every input I tested
    gives main's answer, and the one genuine difference (number.sum reassociating, so
    float rounding may differ) is stated in the entry itself.

Everything settled at 6fafb913end yielding readonly [T], step retyped to
non-nullable _Run<T> (reverting it reintroduces TS18047, so it is load-bearing),
and the 3,000-list randomized foldAbsorbing-vs-fold agreement — stands on code
that has not changed a byte since.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants