Skip to content

types/sorted_list: name the two tail policies keepTail and dropTail - #1546

Merged
sergey-shandar merged 6 commits into
mainfrom
claude/todo-implementation-rifq4g
Aug 14, 2026
Merged

types/sorted_list: name the two tail policies keepTail and dropTail#1546
sergey-shandar merged 6 commits into
mainfrom
claude/todo-implementation-rifq4g

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

Implements fjs/types/sorted_list/todo/tail-reduce-shadowing.md (deleted here).

Why

tailReduce was bound twice with opposite meanings, one shadowing the other:

export const merge = cmp => {
    /** @type {TailReduce<T, null>} */
    const tailReduce = mergeTail                   // keeps the remaining tail
    return genericMerge({ reduceOp: cmpReduce(cmp), tailReduce })(null)
}
const mergeTail = () => identity
const tailReduce = () => () => null                // discards the tail
export const intersect = cmp =>
    genericMerge({ reduceOp: intersectReduce(cmp), tailReduce })(null)

A reader at the merge call site and one at the intersect call site saw the same identifier meaning contradictory tail policies.

What

mergeTailkeepTail, the module-level tailReducedropTail, each passed explicitly. merge collapses to one line and now reads as the same shape as intersect, differing only in reduceOp and tail policy:

cmp => genericMerge({ reduceOp: cmpReduce(cmp), tailReduce: keepTail })(null)
cmp => genericMerge({ reduceOp: intersectReduce(cmp), tailReduce: dropTail })(null)

One deviation from the TODO, and why

The TODO said to "annotate keepTail at module scope, and delete the shadowing local". Deleting the local turns out to break type inference, and annotating keepTail is what breaks it — the opposite of what the TODO expected. The local's TailReduce<T, null> annotation was load-bearing.

With an explicit () => <T>(tail: List<T>) => List<T> on a tail policy, genericMerge collects two inference candidates for T — one from reduceOp (the caller's T) and one from the policy's own type parameter. The best common supertype is unknown, so T widens and both call sites fail against their declared return type:

module.f.mjs(56,27): error TS2322: Type '_CmpReduceOp<T>' is not assignable to type 'ReduceOp<unknown, null>'.
module.f.mjs(100,27): error TS2322: Type 'ReduceOp<T, null>' is not assignable to type 'ReduceOp<unknown, null>'.

I tried three annotation shapes (inner <T>, outer <T>, and spelling out TailReduce) — all widen. Leaving both policies to inference removes the second candidate entirely, so reduceOp alone fixes T and 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 — dropTail happened to be unannotated, and merge paid 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 to unknown).

Verification

  • npx tsc clean.
  • fjs test: 2646 pass, 0 fail. intersect/dropTail are covered through fjs/types/sorted_set, which wraps them.
  • npm run cov: fjs/types/sorted_list/module.f.mjs at 100% lines/branches/functions.
  • Renamed the two stale mergeTail mentions in fjs/types/function/todo/uncurry-accumulator-types.md and todo/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

claude added 2 commits August 14, 2026 07:46
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
@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 71b5f40 Commit Preview URL

Branch Preview URL
Aug 14 2026, 01:24 PM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 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.

Approving — clean. Baseline is origin/main at 6684bef2.

Is this a public surface change?

No. mergeTailkeepTail and the module-level tailReducedropTail 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 to origin/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 prepack from a cleaned tree — exit 0.
  • npm testpass: 2646, fail: 0 on the PR head; pass: 2646, fail: 0 on origin/main.
    Exact match.
  • bin/linkcheck.mjs — broken-link sets identical to main (diff empty). Deleting
    fjs/types/sorted_list/todo/tail-reduce-shadowing.md strands nothing: git grep tail-reduce-shadowing returns 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 the todo/ 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.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 14, 2026

@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 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 prepack from 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 type or export const; keepTail and dropTail remain module-private, so §6.2's _ rule is not engaged.
  • CHANGELOG — one ## Unreleased entry, links only /pull/1546, correctly marked "internal only" and correctly without a **BREAKING CHANGES:** prefix.

Still clean. Approving.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
@sergey-shandar
sergey-shandar removed this pull request from the merge queue due to a manual request Aug 14, 2026
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
@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 14, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@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 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 @import gone.
  • npm run prepack from a cleaned tree — exit 0.
  • npm test2652 pass / 0 fail, exactly origin/main 2a1ee02f's count.
  • Public surface, dual-axis with both trees prepacked — extract.mjs and
    consts.mjs output are byte-identical to main's on both axes. keepTail and
    dropTail are module-private consts, so the rename is invisible outside the file;
    nothing widened to any; no new unprefixed export type.
  • @module survives declaration emit in sorted_list/module.f.d.mts after the
    @import hoist.
  • Link check — broken-link set identical to main's; deleting the completed
    todo/tail-reduce-shadowing.md stranded no references.
  • CHANGELOG — one Unreleased entry 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.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit 6d26e26 Aug 14, 2026
19 checks passed
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