common/monoid: make fold a balanced reduction - #1548
Conversation
`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
Deploying with
|
| 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
left a comment
There was a problem hiding this comment.
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.sum— 281/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.416131745vs...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
nullin 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.productandstring/bit_vecconcatenation 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.concat— not 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_vecconcatenation — was already O(n log n) on main. The deletedlistToVecOpwas a binary-counter accumulator and its own JSDoc said "O(n log n) totalbigintshifting 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
|
All three addressed in 1. Non-termination — reproduced, then documented. I re-ran your experiment against
You are right that "an already-doomed list is still walked to completion" is a statement about finite lists only, and that on I did not fix it here — that is a new combinator in another module, not this PR's one change. Filed as 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
Same directions as yours. Agreed on no Generated by Claude Code |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
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)returnsnullafter 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 asetInterval. 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 returningNullable<Vec>advertises "hand me anything, I will tell you if it does not fit" — andmaxLengthis 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>includesThunk<T>, and nothing at the boundary distinguishes the finite case.
Two ways out, either fine by me:
- Implement the
absorbingproposal in this PR. It is small — an optional field onMonoid, one=== absorbingcheck inpush, andbit_vecsets it tonull. - If it stays deferred, the CHANGELOG entry needs the
**BREAKING CHANGES:**prefix (AGENTS.md §8.4). Published exports on bothlsbandmsb—tryListToVec,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/denono longer exportscoverageInclude). It is not the same case asnumber.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 --noEmitexit 0;npm run prepackexit 0 from a freshly cleaned tree (both passes). -
npm test: 2647 pass / 0 fail, against 2646 on725129fe. The +1 is the newfold.balancedcase. -
Public surface, both axes, after
prepackin each tree:fold's signature goes from({ identity, operation }: Monoid<T>)to(monoid: Monoid<T>)— same type, nothing widened toany._ListToVecOpand_ListToVecStatedrop,_Stack<T>is added; all three are_-prefixed per §6.2, so no unprefixed additions.@modulesurvives emit in both changed modules, noelided/anyin the declarations. -
Broken-link sets identical to main, 154 entries either side — the
todo/balanced-fold.mddeletion 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
push→fold.orderandfold.balancedboth fail - never merge (
stack.size !== size→true) →fold.balancedfails with(((ab)c)d) - swap the operand order in
combine→fold.orderandfold.balancedboth fail size * 2→size→fold.balancedfails
All four caught. The
bracketmonoid infold.balancedis a good way to make grouping observable, and the comment explaining why a deliberately non-associative operation is legitimate there is exactly right. - swap the merge order in
-
bit_vec's three-element overflow addition: dropping either null guard intryUnpackConcat, or making the identitynull, is caught by the co-located proof. Widening the guard to>=survives — but it survives onmaintoo, 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
|
Took option 1 — implemented in
|
| 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 callassert(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 / 2per 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
left a comment
There was a problem hiding this comment.
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;
maxLengthboundary: 60–68 elements ofmaxLength/64; and[vec(maxLength-1+d), vec(1)]fordin -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; tryU8ListToVeclengths 0–40 and atmaxLength/8-2..+2 bytes;- the throwing
u8ListToVecwrapper 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 --noEmit0 on both trees.npm run prepack0 from a freshly cleaned tree, both trees.npm test: 2651 pass / 0 fail here vs 2646 / 0 on main — +5, matching the newfoldAbsorbingproofs andtryListToVecUnbounded.- Public surface (dual-axis, from emitted declarations): exactly two const changes —
foldAbsorbingadded as<T>({ monoid, absorbing }: Absorbing<T>) => (list: List<T>) => T, andfold's parameter rendered asmonoid: Monoid<T>instead of the destructured form (same type). Types:+Absorbing,+_Run,-_ListToVecOp,-_ListToVecState. No signature widened toany._Runcorrectly_-prefixed;Absorbingis genuinely public since it is infoldAbsorbing's signature. @modulesurvives emit in bothmodule.f.d.mtsandtypes.d.ts; noelidedoranyin the monoid declarations.- Broken-link sets identical to main — deleting
types/bit_vec/todo/listtovec-overflow-early-exit.mdstranded nothing.
Mutation tests on the new code, each verified to have landed on the expression and not a JSDoc line:
next.value === absorbing→false(never stop): 2 failures. Killed.result === null ? absorbing : result→result: killed bytsc(Nullable<T>not assignable toT).absorbing: null→absorbing: unpackEmptyintryUnpackConcat: 18 failures. Killed.push(monoid.operation)(1)→(2)inabsorbingAccumulator: 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
-
foldAbsorbingreadstryFold'snullas "stopped", buttryFoldalso returnsend(state)unchanged, so a monoid whoseTincludesnullwith a non-nullabsorbing element would getabsorbingwherefoldreturnsnull. Unreachable in this repo —bit_vecis the only user and its absorbing element isnull, so the two agree — and my sweep confirms it. Worth a line in the JSDoc iffoldAbsorbingever grows a second caller. -
absorbingAccumulatorbuildspush(monoid.operation)(1)itself where the file already hasstep = 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
|
Thanks for the approval. Both nits are addressed in 1. The The proof is a lawful instance of the shape you described: 2. Seed shared. Your equivalent-mutant analysis of Re-verified at Generated by Claude Code |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
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 prepackfrom a clean tree — exit 0.npm test— 2652 pass / 0 fail, vs 2646 on main. +6, of which the newnullValuedproof is one.- Public surface unchanged:
foldAbsorbing: <T>({ monoid, absorbing }: Absorbing<T>) => (list: List<T>) => T, noelidedoranyin the emitted declarations. Thereadonly[T]stays internal. - Link check — broken-link set byte-identical to main.
- CHANGELOG untouched by this delta, correctly: the entries for
common/monoidandtypes/bit_vecalready 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
o2alexanderfedin
left a comment
There was a problem hiding this comment.
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 prepackfrom a cleaned tree — exit 0 (both passes).npm test— 2652 pass / 0 fail, vs 2646 onorigin/main; the +6 are the new
common/monoidandbit_vecproofs.- Public surface (dual-axis, both trees prepacked) — unchanged from what was settled
last round:+Absorbing<T>,+_Run<T>,+foldAbsorbing,-_ListToVecOp,
-_ListToVecState, andfold's emitted parameter renamed from the destructuring
pattern tomonoid(same type). No signature widened toany.
Absorbingbeing unprefixed is correct, not a §6.2 miss: it is the parameter type
of the publicfoldAbsorbing, so it has to be public — the_prefix is for
implementation-only types, and_Runcorrectly carries one. @modulesurvives declaration emit inbit_vec/module.f.d.mts,
monoid/module.f.d.mtsandmonoid/types.d.ts(1 each) — the@importrelocation
did not fold the header into import trivia. Noelidedoranyin either.- Link check — broken-link set is identical to main's, not merely the same count.
- CHANGELOG — the two
Unreleasedentries 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.sumreassociating, so
float rounding may differ) is stated in the entry itself.
Everything settled at 6fafb913 — end 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.
Implements
fjs/common/monoid/todo/balanced-fold.md(deleted here), following its design and its Decisions section.What changed
foldderived from listreduce, 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
Monoidpromises associativity, which is precisely the license to re-parenthesize, sofoldnow reduces as a balanced binary tree — merging runs of equal size, the binary-counter algorithm thatbit_vec.unpackListToVecalready implemented for one concrete operation. The split falls out cleanly and is stated in the JSDoc:list.reducetakes an arbitrary operation with no associativity contract → stays strictly left-to-right;monoid.foldtakes aMonoid→ 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_vecconcat) stay correct.Implementation: an immutable linked stack of
{ size, value, rest }runs.pushmerges while the top run has the same size (the carry of incrementing a binary counter, so the stack holds at mostlog2(n)runs), andcombinefolds the leftover runs earliest-first, seeded atidentity. The list walk itself islist.fold, so laziness is unchanged.bit_vecdrops its private copytryListToVecis now the genericfoldover aNullable<Unpacked>monoid whosenullis an absorbing "longer thanmaxLength" element, exactly as the issue designed it. TheAccumulator/_ListToVecStatebookkeeping 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
nulliff the total exceedsmaxLength, independent of grouping.Two details worth flagging:
maxLengthis the smallestbigintsize 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.nullpropagates to the end of the walk instead of abandoning the list. Reusing one genericfoldis worth finishing a walk over an already-doomed list.unpackListToVec(unpackConcat)is now bound once per bit order rather than rebuilt per call (folddoes its setup at application time). That was one bullet offjs/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:bigint.productmsb.listToVecstring.concatstring.concatis 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 uniformfoldper the issue's decision — one combinator, nofold/foldBalancedsplit — and the cost is a fraction of a millisecond per 10k elements.number.sumre-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 cov—common/monoid/module.f.mjsandtypes/bit_vec/module.f.mjsat 100% lines / branches / functions; repo totals unchanged from before the change (99.96 / 98.62 / 99.80).New proof coverage:
monoid.proof.fold.balancedpins the grouping with a deliberately non-associative bracketing operation (a lawful monoid could not observe grouping at all — that is the point), andbit_vec.proof.tryListToVecOverflowgains a three-element case so the absorbingnullis 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