Implement EOF as semantic symbol -1 with logical synthesis - #1516
Conversation
Semantic EOF moves from `2^24 - 1` to `-1`, outside the non-negative physical-symbol domain, so it no longer depends on how wide a physical symbol is and no alphabet has to reserve one of its own values. The packed `TerminalRange` keeps two 24-bit stored endpoint codes, and every code is unchanged: `rangeEncode`/`rangeDecode` wrap between the semantic terminal and its code branchlessly, so EOF is still stored as `2^24 - 1`. `fullRange` is now `[0, 2^24 - 2]` and complements over it never include EOF. Serialized ranges whose endpoint was the old EOF must be regenerated; there is no compatibility layer. Both parser backends now synthesize exactly one logical EOF after the physical input, so a grammar can require the end of input with the `eof` terminal and callers never append it. Public positions and remainders stay physical: descent tracks the complete `(idx, eofConsumed)` cursor as one extended position — which is what makes EOF consumption count as progress for alternatives, repetition, backtracking and diagnostic ordering — and LL(1) threads the same flag beside its remainder. The synthesized EOF contributes no AST leaf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzUQFRhgzkfv4EySTfaX4V
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | d92abfc | Commit Preview URL Branch Preview URL |
Aug 13 2026, 07:18 AM |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzUQFRhgzkfv4EySTfaX4V
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approving. I reviewed the eof-minus-one.md specification earlier, so this pass checked the implementation against what that design established rather than just re-reading the diff.
Gates
| check | result |
|---|---|
npx tsc --noEmit |
exit 0 |
npm run prepack (freshly cleaned tree) |
exit 0 |
npm test |
2522 pass / 0 fail, vs 2499 on origin/main (+23) |
public type surface (extract.mjs, main vs PR) |
only _Cursor, _Failure, _Result<T>, _MatchResultEof added — all _-prefixed per §6.2; DescentMatchRule / MatchRule changed shape, no widening to any |
| exported const surface | unchanged except the new eofSymbol; emitted fjs/bnf/module.f.d.mts diff is exactly eofSymbol = -1 plus doc text, no elided/any |
linkcheck.mjs |
broken-link sets identical to main (162 lines each, zero-diff) — deleting todo/eof-minus-one.md stranded nothing, and the five inbound references were all repointed at README.md#terminals-and-eof / #logical-eof-in-parser-input, both of which are real anchors |
@module headers |
present in every emitted .d.mts/.d.ts for the touched modules |
| CHANGELOG | §8.3/§8.4 satisfied: top of ## Unreleased, **BREAKING CHANGES:** prefix, links only /pull/1516 |
| Rust / generator round-trip | not applicable, nanvm-lib/ and fjs/nanvm/ untouched |
Codec
The implementation matches the spec's formulas (mask is the doc's terminalMask, terminalSize = mask + 1). I re-verified exhaustively against the code as written, not the doc:
- all 16,777,216 semantic values
-1 .. 2^24 - 2encode to 16,777,216 distinct codes, 0 round-trip failures; - the reverse direction too: every one of the 2^24 stored codes decodes into the semantic domain and re-encodes to itself.
Negative-controlled by perturbing decodeTerminal to ((value + 2) & mask) - 1, which fails all 16,777,216 in both directions.
Ordering
This is the defect the design invites, so I checked every comparison the change touches, and all of them are on decoded values:
removeOnedecodes both operands before thea0 < a/b < b0comparisons and beforeMath.min/Math.max;descentcomparesrangeContains(...rangeDecode(rule))(symbolAt(...));ll1builds its dispatchrangeMapfromrangeDecode(rule), so stored codes never reach the map — the emitted cut point foreofis[[null, -2], [·, -1]], which is what the new proof pins.
Behavioural spot-checks past the ASCII range where an encoded comparison would have shown up: not({e: eof}) → [[0, 16777214]] (EOF is not in any complement); remove([-1, 5], {eof}) → [[0, 5]]; remove([-1, 5], {2}) → [[-1, 1], [3, 5]]; a terminal spanning [-1, 'A'] matches empty input, A, and rejects B in both backends.
The beforeEOF || afterEOF correction
Taken in the corrected form, and existing whole-input grammars still accept. In ll1, the empty-input path only diverges from main's when dispatchOp.get(rangeMap)(eofSymbol) is non-null; otherwise it returns main's mrSuccess(emptyTag, [], emptyTag === undefined ? null : cp) verbatim, so a grammar that does not mention eof is untouched. descent likewise only reaches the new pos === cp.length case for a rule that can match EOF. Verified end-to-end: fjs/fsc/json.f.mjs still parses {"a":[1,2,null]} on both backends (descent idx 16 of 16), and the body eof idiom accepts ABC / rejects AB1 on both, with descent reporting failure.idx = 2, expected = [range('AZ'), eof].
Also checked the constants you flagged: offset = 24 and mask = 16777215 are unchanged; eof still packs to 0xFFFFFF_FFFFFF so old serialized eof reads correctly; fullRange moved 0x000000_FFFFFF → 0x000000_FFFFFE, and unicodeRange is unaffected because ordinary symbols encode to themselves. grep over the tree found no remaining literal carrying the old EOF code, so there is no stale in-repo range needing regeneration. token_symbol.capacity is arithmetically identical to main's (0xFFFFFE - 0x110000 + 1 = 0xFFFFFF - 0x110000), so that alphabet did not silently resize.
New proofs actually cover the new paths
Mutation-tested rather than assumed — 14 mutants against the bnf proof trees:
killed — decodeTerminal identity (14 fails), maxSymbol off-by-one (4), fullRange back to the old literal (6), isValid excluding EOF (module load throws), ll1 EOF made repeatable (1), ll1 EOF not marked consumed (1), ll1 EOF synthesis removed (4), descent pos <= cp.length → < (6), symbolAt returning -2 (6), physicalIdx not clamping (7), leafAt emitting an EOF leaf (5), descent failure recorded on the physical index instead of the complete cursor (1).
survived — two, neither a coverage gap I would act on:
encodeTerminal→value & maskis an equivalent mutant: adding2^24cannot change the low 24 bits, so the two expressions agree on the whole domain.capacity = lastOrdinary - start + 1→+ 2survives because the only test that exercises the bound,throw.tooManyNames, builds its array fromcapacityitself. Pre-existing — main'scapacity = eofSymbol - starthas the same self-referential pin — and the value is provably unchanged, so nothing regressed. If you want it pinned,assertEq(capacity, 0xFFFFFE - 0x110000 + 1)would do it.
Coverage
npm run cov gives real numbers here (Node v23.11.0), so the new branches are measured, not assumed:
| module | main | PR |
|---|---|---|
bnf/module.f.mjs |
100.00 / 100.00 / 100.00 | 100.00 / 100.00 / 100.00 |
bnf/descent/module.f.mjs |
100.00 / 100.00 / 100.00 | 100.00 / 100.00 / 100.00 |
bnf/ll1/module.f.mjs |
100.00 / 98.31 / 100.00 | 100.00 / 98.44 / 100.00 |
bnf/token_symbol/module.f.mjs |
100.00 / 100.00 / 100.00 | 100.00 / 100.00 / 100.00 |
(line / branch / function.) descent holds 100% branch across a considerably larger module, and ll1's branch number moved up; the residual gap there is pre-existing.
One optional nit
ll1/types.ts documents MatchRule as "Internal match function signature used by compiled dispatch rules", and it is used only inside ll1/module.f.mjs — yet it is exported unprefixed while the sibling this PR adds is _MatchResultEof. Since you are changing its signature anyway, this is a cheap moment to make it _MatchRule. Not a blocker, and not something this PR introduced.
Nothing else. The [eof, eof] LL(1) result (success: true, null remainder) reads oddly, but that is this backend's pre-existing "ran out of input" convention — main returns the same shape for [range('AA'), range('BB')] on "A" — and the proof comment says so explicitly. descent reports it as a proper failure.
Release under the new directory-per-version changelog workflow: changelog/unreleased/ is renamed to changelog/0.45.0/ with its entry files kept as they are (.gitkeep dropped — the next entry PR recreates unreleased/). Minor bump: the release contains BREAKING CHANGES entries (#1516, #1520, #1530, #1531, #1547). Update AGENTS.md §8.3–8.4 and changelog/README.md for the new workflow: releasing renames the directory instead of concatenating entries, and future entries carry no PR number or link inside the file — the file name already has it. Released entries are kept as-is. Extend todo/changelog-website.md so the future generator reads both release forms: <version>.md files (through 0.44.0) and <version>/ directories. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M296KXwQHHuUGhryRReKpJ
Review follow-ups from the human approval: stage 5 now states where the rest of fjs/djs lands (serializer and value-tree types into stage 4's fjs/media/datajs, examples and the top-level compile() module with the front end to fsc), and the compile-modules-to-edag edit note distinguishes its front-end paths (djs -> fsc) from its serializer citation (-> media/ datajs). The bnf EOF-encoding change is cited as shipped (#1516) rather than listed as still pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PfpYZSMQKCJvViyebNyLho
Summary
This change implements the design from the removed
eof-minus-one.mdtask, moving EOF from the top of the 24-bit terminal space (2^24 - 1) to the semantic symbol-1, outside the non-negative physical-symbol domain. The stored representation remains unchanged (EOF still uses stored code2^24 - 1), but the semantic ordering is inverted: EOF is now the smallest terminal, andfullRangecontains only ordinary symbols[0, 2^24 - 2].Key Changes
Terminal encoding/decoding: Added branchless
encodeTerminalanddecodeTerminalfunctions that map between semantic values and 24-bit stored codes using(value + 2^24) & maskand its inverse. This preserves existing stored codes while changing semantic ordering.Range operations: Updated
rangeEncode,rangeDecode,eof, andfullRangeto work with the new semantic domain.eofis nowoneEncode(-1)andfullRangeisrangeEncode(0, maxSymbol)wheremaxSymbol = 2^24 - 2.Parser backends (descent and LL(1)): Implemented logical EOF synthesis:
(idx, eofConsumed)as a single position value to handle EOF consumption as progress0 <= idx <= input.length)input.lengthDescent parser: Refactored to use
_Cursortype (extended position including EOF consumption), updatedsymbolAtandleafAthelpers to synthesize EOF, and modified failure tracking to use the complete cursor for ordering.LL(1) parser: Added
eofConsumedflag threading through the match state to ensure exactly one EOF is consumed, with dispatch oneofSymbolonly at the physical end of input.Proofs and tests: Added comprehensive test coverage for logical EOF behavior: empty input matching, single EOF synthesis, backtracking, alternatives, repetition, and diagnostic ordering.
Documentation: Updated README files and type comments to explain the new EOF semantics, stored vs. semantic distinction, and the complete cursor design.
Notable Implementation Details
0x000030_000039still read as their endpoints, but range operations comparing terminals must use decoded values.<without branching on whether EOF was consumed.https://claude.ai/code/session_01WzUQFRhgzkfv4EySTfaX4V