types/sorted_list: name the two tail policies keepTail and dropTail - #1546
Conversation
The name `tailReduce` was bound twice with opposite meanings -- a local in `merge` that keeps the remaining tail, shadowing a module const that discards it -- so the same identifier at two call sites meant contradictory things. Rename them for what they do and pass each explicitly, leaving `merge` and `intersect` as the same one-line shape differing only in `reduceOp` and tail policy. Dropping the local also meant dropping its `TailReduce<T, null>` annotation, which turned out to be load-bearing: an explicit generic annotation on a tail policy gives `genericMerge` a second inference candidate for `T` whose best common supertype with `reduceOp`'s is `unknown`, widening `T` and breaking both call sites. Leaving both policies to inference removes the candidate, so `reduceOp` alone fixes `T` -- and matches AGENTS.md 6.2 on preferring inference for private constants. Recorded in a comment where the annotation used to be. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | 71b5f40 | Commit Preview URL Branch Preview URL |
Aug 14 2026, 01:24 PM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb2c2adbf0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const tailReduce = mergeTail | ||
| return genericMerge({ reduceOp: cmpReduce(cmp), tailReduce })(null) | ||
| } | ||
| cmp => genericMerge({ reduceOp: cmpReduce(cmp), tailReduce: keepTail })(null) |
There was a problem hiding this comment.
Add the required CHANGELOG entry
This commit changes executable fjs/types/sorted_list code, but the inspected CHANGELOG.md has no corresponding entry under ## Unreleased; add a concise entry linked to the real PR so this code change is included in the release notes.
AGENTS.md reference: AGENTS.md:L133-L135
Useful? React with 👍 / 👎.
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approving — clean. Baseline is origin/main at 6684bef2.
Is this a public surface change?
No. mergeTail → keepTail and the module-level tailReduce → dropTail are both
module-private consts, and the shadowing local inside merge was private by construction. I
checked rather than assumed, with npm run prepack run in both trees and the dual-axis
extractors over the emitted declarations:
bin/extract.mjs(exported type aliases) — byte-identical toorigin/main.bin/consts.mjs(exported const signatures) — byte-identical.
So §8.4 does not apply: no **BREAKING CHANGES:** prefix, and the CHANGELOG's "internal only"
is literally true at the emitted-declaration level. This sits with #1524 (representation-only,
correctly unprefixed), not #1520.
TailReduce itself is not orphaned by dropping it from this module's @import — it is still
declared in sorted_list/types.ts, still part of _MergeReduce, and still @imported and used
by types/range_map/module.f.mjs:73.
Behaviour: is the split policy-preserving?
merge was one function with a local annotated alias resolving to mergeTail; intersect used
a differently-named module-level const. They are now two named consts passed explicitly. I swept
both against origin/main over 60,000 random sorted-list pairs (values 0–11, lengths 0–6, so
empty inputs, disjoint pairs, identical pairs, prefixes and one-side-exhausted-first cases all
occur densely — the last is the only shape where the tail policy is observable at all):
PR head 7376d128: cases 60000, merged elements 273841, intersected 33662, errors 0
sha256 7f48d689d3de824b8e5a6ececc826e62f753bc502544d34cda7ddde7e043681e
origin/main 6684bef2: cases 60000, merged elements 273841, intersected 33662, errors 0
sha256 7f48d689d3de824b8e5a6ececc826e62f753bc502544d34cda7ddde7e043681e
Identical hash over [a, b, merge(a,b), intersect(a,b)] for every case, and every case also
checked against an independent oracle (sorted set union / filter-by-membership) — 0 mismatches
on both sides.
Negative control: swapping the two policies at the call sites
(merge gets dropTail, intersect gets keepTail) breaks on the 2nd case with both a
MERGE and an INTERSECT mismatch and a different hash. The sweep distinguishes the two policies,
so the pass above is meaningful.
§3.2 — is the coverage real, or just case names?
Mutation-tested rather than read: with the same policy swap applied, the repository's own
suite fails 13 of 2646 (pass: 2633, fail: 13). The existing proofs genuinely discriminate
the two tail policies, so the rename does not need a new co-located proof — nothing new is
public, and what changed is already killed by existing tests.
The comment's inference claim
The new block comment asserts that annotating keepTail with
() => <T>(tail: List<T>) => List<T> introduces a second inference candidate, widens T to
unknown, and breaks both call sites. I applied exactly that annotation and re-ran tsc:
module.f.mjs(56,12): TS2322: Type '(a: List<unknown>) => (b: List<unknown>) => List<unknown>'
is not assignable to type '(a: SortedList<T>) => (b: SortedList<T>) => SortedList<T>'
… 'unknown' is not assignable to type 'T'
module.f.mjs(56,27): TS2322: Type '_CmpReduceOp<T>' is not assignable to
type 'ReduceOp<unknown, null>' …
The claim reproduces, including the mechanism (unknown as the widened T). Documenting a
non-obvious "why there is no annotation here" is worth the lines.
It is also consistent with AGENTS.md "Prefer inference", which happens to use
const f: TailReduce<unknown, unknown> = () => () => null as its example of the annotation not
to write — this change removes precisely that shape — and with §6.3, which the deleted
tail-reduce-shadowing.md cited for hoisting the non-capturing local.
Battery
npx tsc --noEmit— exit 0.npm run prepackfrom a cleaned tree — exit 0.npm test—pass: 2646, fail: 0on the PR head;pass: 2646, fail: 0onorigin/main.
Exact match.bin/linkcheck.mjs— broken-link sets identical to main (diffempty). Deleting
fjs/types/sorted_list/todo/tail-reduce-shadowing.mdstrands nothing:git grep tail-reduce-shadowingreturns no hits at the head, and the two prose references to
mergeTail(uncurry-accumulator-types.md,todo/migrate-typescript-to-mjs.md) are both
updated in the same PR rather than left stale.- Rust gates skipped:
nanvm-lib/untouched. - §8.3: one entry, code change, PR link only, released sections untouched, and correctly no
separate entry for thetodo/deletion or the two doc touch-ups. - §6.2: no new or renamed exported types, so the
_-prefix question does not arise here.
Nothing to change.
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Re-reviewed at 053c7ed3. The delta since my approval at 7376d128 is merge-only.
git log 7376d128..053c7ed3 is one merge commit plus main's #1544. Comparing each head against its own merge-base with main, the PR's own diff is byte-identical apart from two lines of CHANGELOG context (the neighbouring entry is now #1544's instead of #1543's) — fjs/types/sorted_list/module.f.mjs, the two todo files and the entry itself are unchanged. So there is no new implementation to re-derive against, and I have not carried the previous sweep forward as evidence for changed code; there is no changed code.
Re-run on the merged tree, baseline origin/main = e2199caf:
npx tsc --noEmit— exit 0.npm run prepackfrom a cleaned tree — exit 0 (both passes).npm test— 2646 pass / 0 fail, exactly the baseline count.- Broken-link set from
linkcheck— identical to main, not just equal in count. - Public surface — the diff adds no
export typeorexport const;keepTailanddropTailremain module-private, so §6.2's_rule is not engaged. - CHANGELOG — one
## Unreleasedentry, links only/pull/1546, correctly marked "internal only" and correctly without a**BREAKING CHANGES:**prefix.
Still clean. Approving.
The merge-queue resolution of the #1545 @import sweep kept TailReduce in the module header, but this branch removed its last use when the shadowing local in `merge` went away. The type itself stays in types.ts, where range_map still imports it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Re-reviewed and still approving. The head moved twice while I was working, so to be
explicit about what I actually tested: I verified 2dac8206, then #1548 merged to
main as 2a1ee02f and this branch merged it as 71b5f40d, so I reset my baseline to
the new origin/main (2a1ee02f) and re-ran the whole battery. Everything below is
measured at 71b5f40d against origin/main = 2a1ee02f.
What moved since 053c7ed3: two merges of main, and one real one-line commit.
#1545's @import sweep hoisted the tags into the module header, and the merge-queue
resolution kept TailReduce there even though this branch had removed its last use
when the shadowing local in merge went away. 2dac8206 drops it. I checked the
claim rather than taking it from the message: TailReduce no longer appears anywhere
in sorted_list/module.f.mjs, and it is still exported from sorted_list/types.ts
and still imported by range_map/module.f.mjs (line 38, used at line 70), so nothing
was orphaned. fjs/types/sorted_list/ is byte-identical between 2dac8206 and
71b5f40d — the last merge is pure #1548.
Because sorted_list/module.f.mjs moved, I re-ran both load-bearing experiments at
this head instead of carrying them forward.
60,000-pair behaviour sweep — randomized sorted-list pairs, merge and
intersect against an independent set-union / filter-based-intersection oracle:
{"cases":60000,"mergedTotal":273841,"interTotal":33662,"errs":0,
"hash":"7f48d689d3de824b8e5a6ececc826e62f753bc502544d34cda7ddde7e043681e"}
Zero mismatches, and the digest is identical to the one from 2dac8206, as it should
be for byte-identical source. Swap negative control, verified to land on code (I
re-grepped both call sites after the edit; it is a three-way rename, so it cannot
silently no-op): exchanging keepTail and dropTail at the two genericMerge call
sites breaks on the first sampled case — merge([5,9,11],[3,9]) gives [3,5,9]
instead of [3,5,9,11], intersect gives [9,11] instead of [9]. The sweep is
therefore capable of failing, and the policy split really is behaviour-preserving.
§3.2 is still satisfied without new proofs: the same verified swap fails
13 of 2652 existing tests, so both policies are already exercised along both paths.
Rest of the battery at 71b5f40dcaab86baa2af9267f4dd2131de3e023f:
npx tsc --noEmit— exit 0, including with the now-unused@importgone.npm run prepackfrom a cleaned tree — exit 0.npm test— 2652 pass / 0 fail, exactlyorigin/main2a1ee02f's count.- Public surface, dual-axis with both trees prepacked —
extract.mjsand
consts.mjsoutput are byte-identical to main's on both axes.keepTailand
dropTailare module-private consts, so the rename is invisible outside the file;
nothing widened toany; no new unprefixedexport type. @modulesurvives declaration emit insorted_list/module.f.d.mtsafter the
@importhoist.- Link check — broken-link set identical to main's; deleting the completed
todo/tail-reduce-shadowing.mdstranded no references. - CHANGELOG — one
Unreleasedentry for this PR, links only/pull/1546, correctly
marked internal-only; no**BREAKING CHANGES:**needed given the identical surface.
The comment explaining why both policies are left to inference — an explicit
() => <T>(tail: List<T>) => List<T> introduces a second inference candidate that
widens T to unknown — matches what the compiler actually does.
Implements
fjs/types/sorted_list/todo/tail-reduce-shadowing.md(deleted here).Why
tailReducewas bound twice with opposite meanings, one shadowing the other:A reader at the
mergecall site and one at theintersectcall site saw the same identifier meaning contradictory tail policies.What
mergeTail→keepTail, the module-leveltailReduce→dropTail, each passed explicitly.mergecollapses to one line and now reads as the same shape asintersect, differing only inreduceOpand tail policy:One deviation from the TODO, and why
The TODO said to "annotate
keepTailat module scope, and delete the shadowing local". Deleting the local turns out to break type inference, and annotatingkeepTailis what breaks it — the opposite of what the TODO expected. The local'sTailReduce<T, null>annotation was load-bearing.With an explicit
() => <T>(tail: List<T>) => List<T>on a tail policy,genericMergecollects two inference candidates forT— one fromreduceOp(the caller'sT) and one from the policy's own type parameter. The best common supertype isunknown, soTwidens and both call sites fail against their declared return type:I tried three annotation shapes (inner
<T>, outer<T>, and spelling outTailReduce) — all widen. Leaving both policies to inference removes the second candidate entirely, soreduceOpalone fixesTand everything checks. That is also what AGENTS.md §6.2 prescribes: these are private module constants whose inferred types are correct, and the annotation was actively wrong. The old code got away with it only by accident —dropTailhappened to be unannotated, andmergepaid for its annotation with the shadowing local.The reasoning is recorded in a comment where the annotation used to be, since a future reader would otherwise "fix" the missing annotation and reintroduce the failure.
This is a fresh instance of the inference hazard already described in
todo/migrate-typescript-to-mjs.md(composing independently-generic helpers inside a generic function silently widens parameters tounknown).Verification
npx tscclean.fjs test: 2646 pass, 0 fail.intersect/dropTailare covered throughfjs/types/sorted_set, which wraps them.npm run cov:fjs/types/sorted_list/module.f.mjsat 100% lines/branches/functions.mergeTailmentions infjs/types/function/todo/uncurry-accumulator-types.mdandtodo/migrate-typescript-to-mjs.md; no reference to the old name remains.🤖 Generated with Claude Code
https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
Generated by Claude Code