Skip to content

Feature/nat literal term - #924

Closed
MattCozendey wants to merge 8 commits into
bendlang:mainfrom
MattCozendey:feature/nat-literal-term
Closed

MattCozendey wants to merge 8 commits into
bendlang:mainfrom
MattCozendey:feature/nat-literal-term

Conversation

@MattCozendey

@MattCozendey MattCozendey commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

A nat literal is one Lit node in the checker

A nat literal up to 256 parsed as a Succ chain, and a larger one as
U32.to_nat(n), an application. Two forms meant one literal: the parser, the
pattern reader and the printer each knew both, and a self-call on a literal
past 256 failed the termination check, since the check reads constructors
and saw a function call. A Lit now holds a number as it holds a text, and
the same peel reads it: 0n is Zero{}, n is Succ{n-1}.

What changes

  • The checker unfolds a nat literal one Succ at a time where a chain is
    read: a match frame, a comparison, a descent, and the check rule's
    fallback. Against Nat it checks in O(1), with the same guard the string
    rule has: the Nat in scope must declare Succ and Zero.
  • Inference steps a literal once, so an undeclared Nat still reports
    "a declared constructor".
  • The printer prints a literal as its number, and a Succ chain that ends
    in a literal as one number.
  • "1n+0n" parses as 1n, so a template's ~ key agrees with 1n.
  • nat_to_term and the App form of a nat pattern are gone. NAT_LITERAL_MAX
    stays for two uses: "n + T" past it is Nat.add(n, T), and the compiler
    emits U32.to_nat(n) past it. U32.to_nat is an intrinsic ($0 in C,
    BigInt($0) in JS), so this costs nothing at run time.
  • comp.ts reads a literal through term_lit in term_kids and term_any, so
    the reference to U32.to_nat counts for reachability.

Emitted code and behavior

  • The C and JS of every runnable test are byte-identical to the base.
  • The checker's output is the same on every non-IO test (1291).
  • 2000 defs of 200n: 1.06 s and 366 MB to 0.18 s and 83 MB.
  • case 300n: down(299n) now passes the termination check.

Tests

  • tests/proof/nat_literal_unfolds.bend: a comparison sees 300n as
    Succ{299n} and 0n as Zero{}, a match reads a literal pattern past 256
    and a literal scrutinee, a self-call on a smaller literal descends, a
    literal at the u32 bound passes through Nat.add unforced.
  • tests/check/nat_literal_own_nat.bend: a module's own Nat with other
    constructors rejects 3n with the base's exact error.
  • tests/printer/nat_literal_print.bend: a literal prints back as a
    literal, "n+T" past 256 is Nat.add, a chain that ends in a literal
    prints as one number.
  • tests/check/nat_literal_pattern_empty.bend spells its pattern 0n. It
    wrote U32.to_nat(0), the form the parser gave a literal, which is no
    pattern anymore. It prints the same error.

Size: bend2/bend.ts 42981 and bend2/comp.ts 64977 ttok (cl100k), under
the caps of 43000 and 65000 with no room to spare. If your ttok reads
higher, the caps need to move.

🤖 Generated with Claude Code

Matt Pereira and others added 7 commits September 20, 2026 18:31
…ck frame per character, and the emitted code is the same

The parser turned "text" into SCon{Chr{U32{WCon{..}}}, ..}, about 70 nodes per character; the checker lifted, copied, checked and kept the chain, so a literal cost about 40 KB per character and one past about 5000 characters overflowed the host stack in term_check, as a Nat literal did before 2.0.7. A string literal now parses as one Lit node holding its text, typed String, that unfolds a character at a time where a chain is read: a match frame in term_wnf, term_compare against a constructor, term_descend against a constructor column, a literal pattern, and check-lit's fallback for any type but String. comp.ts expands every literal back into its chain in def_body, so the emitted C and JS are byte-identical to main for every test. 50 defs of a 1000-character literal: 2.60 GB and 4.00 s to 74 MB and 0.14 s; a 200,000-character literal checks in 0.17 s. A literal that spells a surrogate or a code point past U+10FFFF still parses as its chain. bend2/bend.ts may reach 43500 ttok (41915 to 43203). Four tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e surrogate

tests/check/string_literal_surrogates.bend ran String.length("\u{d83d}\u{de00}")
in main, and every test with a main and Base runs in the C and JS lanes;
the JS runtime aborts on a lone surrogate (as on main), so the gate would
fail. The same check is now the law surrogates_two in
tests/proof/string_literal_unfolds.bend, which the checker verifies and the
compiler never emits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ompiler expands a literal where it reads a constructor

lit_expand walked a whole first-order term to turn every Lit into its chain
before compilation, so comp.ts saw no Lit. A Lit has no sub-terms, so
parse_patt calls lit_full, the chain of one literal. The compiler matches
constructors in term_const, ty_peel and the Mat scrutinee of emit_unfold,
so term_lit, memoized per node, hands them a Lit's chain there, and
def_body no longer pre-expands. The emitted code is unchanged: the JS of
the 164 runnable tests with a string literal is byte-identical, and the
checker's output is the same on every non-IO test. bend2/bend.ts may
reach 43000 ttok (43203 to 42853).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a module's own String

Both hold on main and had no test. A literal argument that is a strict
tail of its case's literal pattern descends: case "ab" may call f("b"),
which needs term_descend to read the literal as its chain. A literal is a
String only where String declares SCon and SNil: a module that declares its
own type String is Data: Foo{} rejects "abc" with main's exact error, which
needs check-lit's ctrs_find guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…to_nat(0)

The test proves a literal pattern is refused like Zero{} is when Zero
carries a field. It wrote the pattern as U32.to_nat(0), the form the parser
gave a nat literal, which reads as a test of that longhand. It now writes
0n, the literal it is about, and prints the same error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and the emitted code is the same

A nat literal up to 256 was a Succ chain and a larger one U32.to_nat(n), an
application, so two forms meant one literal: the parser, the pattern reader
and the printer each knew both, and a self-call on a literal past 256 failed
the termination check. A Lit now holds a number as it holds a text: it
unfolds a Succ at a time where a chain is read (a match frame, a comparison,
a descent, the check rule's fallback), checks in O(1) against Nat, infers
through its first step (so an undeclared Nat still reports "a declared
constructor"), prints as its number (a Succ chain that ends in a literal
prints as one number), and "1n+0n" parses as 1n so template keys agree.
NAT_LITERAL_MAX stays for "n + T", which is Nat.add(n, T) past it, and for
the compiler, whose term_lit emits U32.to_nat(n) past it and whose term_kids
and term_any read a literal through it, so the C and JS of every test are
byte-identical. The checker's output is the same on every non-IO test.
2000 defs of 200n: 1.06 s and 366 MB to 0.18 s and 83 MB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… how it prints

nat_literal_unfolds: 300n is Succ{299n} and 0n is Zero{} to a comparison, a
match reads a literal pattern past 256 and a literal scrutinee, case 300n
may call down(299n) (the termination check reads the literal), and a
literal at the u32 bound passes through Nat.add unforced. The patterns past
256 live in laws: a compiled match on one rebuilds its scrutinee 300 deep,
past clang's bracket nesting, on main as well. nat_literal_own_nat: a
module's own type Nat with other constructors rejects 3n with main's exact
error. nat_literal_print: a literal prints back as a literal, "n+T" past 256
is Nat.add, and a Succ chain that ends in a literal prints as one number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@MattCozendey

Copy link
Copy Markdown
Contributor Author

This should only be considered if(and only if) #907 is merged as well, since it builds from it.

A Succ emitted nat_chk(e, $0 + 1) around its field, so a Succ on a Succ
nested the checks: a literal pattern past 256 whose default arm uses the
scrutinee rebuilt it 300 levels deep, and clang refused the bracket nesting
(256), on main as well. tpl_nat now reads a field that is a check of a sum,
nat_chk(e, x + k) or nat_chk(x + kn), and bumps its constant: one call for
the run, in C and JS. The result is the same, as nat_chk saturates (C) or
throws (JS) past NAT_IMM, and x + k passes it whenever x + 1 does. Of 540
runnable tests, 10 emit differently and all print their lines. One test.
bend2/comp.ts is 64998 ttok against a cap of 65000.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VictorTaelin pushed a commit that referenced this pull request Sep 21, 2026
A literal no longer costs a constructor chain per character or per Succ:
the parser makes one Lit, the checker unfolds it one step where a match,
a comparison, a descent or the check rule needs a constructor, and the
compiler expands it where it reads one, so the emitted C and JS are the
same. A self-call on a literal past 256 now passes the termination check,
and "1n+0n" parses as 1n (PRs #907 and #924 by MattCozendey).
VictorTaelin added a commit that referenced this pull request Sep 21, 2026
… one function

Every read in comp.ts goes through term_force or term_strip, which now expand a
Lit once (a nat past NAT_LITERAL_MAX to U32.to_nat); term_lit and its five call
sites go. facts_packed, which read a Lit raw, sees the chain again: "" at a
borrowed parameter is packed, so the parameter stays borrowed, as on 2.0.23;
under the six-call design it owned the parameter and heated String.

ctr_adt and mat_adt each looked a datatype up and refused an open Array element
with a copy of lay_el's check (arr_open); js_expr held a third copy. adt_of
does the lookup and asks lay_el, where the rule lives.

comp.ts 65011 -> 64936 ttok. Emitted C and JS byte-identical to the Lit
branch over tests/{run,compile,rfc,reg,cost,io,base} and bench/runtime;
tests/compile/literal_reads pins the borrowed "" (#907, #924)
@VictorTaelin

Copy link
Copy Markdown
Contributor

Merged, thank you very much. Both PRs landed on main as one commit under your authorship, 942573f "A string or nat literal is one Lit node in the checker", and bend.ts's cap moved to 43000 for it. The design is the right one: the core keeps no literal form, the checker reads a compact node and unfolds it one step where a constructor is needed, and the emitters see the chain they always saw. The numbers reproduced here, and a self-call on a literal past 256 passing the termination check is a real fix on its own. It ships in the next release.

Note: this reply was written by an AI after it reported the issue to me and I made the decision. If anything here is wrong, reply and I will review it myself.

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.

2 participants