Skip to content

Implement EOF as semantic symbol -1 with logical synthesis - #1516

Merged
sergey-shandar merged 5 commits into
mainfrom
claude/eof-minus-one-impl-iewkan
Aug 13, 2026
Merged

Implement EOF as semantic symbol -1 with logical synthesis#1516
sergey-shandar merged 5 commits into
mainfrom
claude/eof-minus-one-impl-iewkan

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

Summary

This change implements the design from the removed eof-minus-one.md task, 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 code 2^24 - 1), but the semantic ordering is inverted: EOF is now the smallest terminal, and fullRange contains only ordinary symbols [0, 2^24 - 2].

Key Changes

  • Terminal encoding/decoding: Added branchless encodeTerminal and decodeTerminal functions that map between semantic values and 24-bit stored codes using (value + 2^24) & mask and its inverse. This preserves existing stored codes while changing semantic ordering.

  • Range operations: Updated rangeEncode, rangeDecode, eof, and fullRange to work with the new semantic domain. eof is now oneEncode(-1) and fullRange is rangeEncode(0, maxSymbol) where maxSymbol = 2^24 - 2.

  • Parser backends (descent and LL(1)): Implemented logical EOF synthesis:

    • Callers pass physical symbols only; parsers synthesize exactly one EOF after the input
    • Internally track a complete cursor (idx, eofConsumed) as a single position value to handle EOF consumption as progress
    • Public positions remain physical (0 <= idx <= input.length)
    • EOF contributes no AST leaf and diagnostics point at input.length
  • Descent parser: Refactored to use _Cursor type (extended position including EOF consumption), updated symbolAt and leafAt helpers to synthesize EOF, and modified failure tracking to use the complete cursor for ordering.

  • LL(1) parser: Added eofConsumed flag threading through the match state to ensure exactly one EOF is consumed, with dispatch on eofSymbol only 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

  • The stored endpoint codes are unchanged, so packed literals like 0x000030_000039 still read as their endpoints, but range operations comparing terminals must use decoded values.
  • Serialized BNF ranges from the old semantics are incompatible; no compatibility layer is provided—in-repo data must be regenerated.
  • The complete cursor design allows all progress comparisons (sequencing, alternatives, repetition, backtracking, failure ordering) to use plain < without branching on whether EOF was consumed.
  • EOF is synthesized only once per match, so a grammar requiring multiple EOF terminals will fail after the first one is consumed.

https://claude.ai/code/session_01WzUQFRhgzkfv4EySTfaX4V

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 13, 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 d92abfc Commit Preview URL

Branch Preview URL
Aug 13 2026, 07:18 AM

@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. 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 - 2 encode 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:

  • removeOne decodes both operands before the a0 < a / b < b0 comparisons and before Math.min / Math.max;
  • descent compares rangeContains(...rangeDecode(rule))(symbolAt(...));
  • ll1 builds its dispatch rangeMap from rangeDecode(rule), so stored codes never reach the map — the emitted cut point for eof is [[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_FFFFFF0x000000_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:

  • encodeTerminalvalue & mask is an equivalent mutant: adding 2^24 cannot change the low 24 bits, so the two expressions agree on the whole domain.
  • capacity = lastOrdinary - start + 1+ 2 survives because the only test that exercises the bound, throw.tooManyNames, builds its array from capacity itself. Pre-existing — main's capacity = eofSymbol - start has 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.

@sergey-shandar
sergey-shandar marked this pull request as ready for review August 13, 2026 09:07
@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 7ae03f0 Aug 13, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/eof-minus-one-impl-iewkan branch August 13, 2026 21:10
@sergey-shandar sergey-shandar mentioned this pull request Aug 14, 2026
sergey-shandar pushed a commit that referenced this pull request Aug 14, 2026
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
sergey-shandar pushed a commit that referenced this pull request Aug 28, 2026
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
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