Skip to content

feat(interp): a value shares its parts - #444

Merged
hyperpolymath merged 10 commits into
mainfrom
claude/e2e-language-pass-ow72tj-values
Nov 10, 2026
Merged

hyperpolymath merged 10 commits into
mainfrom
claude/e2e-language-pass-ow72tj-values

Conversation

@hyperpolymath

@hyperpolymath hyperpolymath commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Pull request 2 of 2 for the interpreter. Pull request 1, hyperpolymath/ephapax#443 (the evaluator with its own stack), is merged, and the base of this one is main.

A Value held its parts in boxes, and it copied, printed and freed them with native recursion. A list is a chain of 2 parts for each element, so a long list overflowed the native stack:

Operation Elements at the overflow (debug to release, stack of 2 MB to 8 MB)
Copy 1000 to 6700
Print 800 to 18000
Free 10000 to 64000

Each read of a variable copied the value. So a function on a list of n elements needed time and memory of the order n * n.

Changes

All in src/ephapax-interp/src/lib.rs.

  • The parts of a value are shared pointers (Arc): Pair, Left, Right, Borrow, Tuple, List, and Closure(Arc<Closure>). A copy is 1 step.
  • Drop for Value frees the parts with a loop.
  • Display prints with a loop. Debug is the printed form.
  • is_linear uses a loop.
  • to_type gives the type to 64 levels of parts, and the placeholder () below that. The REPL uses it to show a type.
  • New constructors: Value::pair, Value::left, Value::right, Value::borrow.

No crate outside the interpreter builds or takes apart a value with parts.

Measurements

Release build:

Program With #443 only With this change
List of 2000 elements (build, then length) 2.95 s, 3 GB 0.01 s, 12 MB
List of 100000 elements abort value, 0.33 s, 179 MB
sum(100000) 138 MB 71 MB
Calls with no end error after 0.53 s, 669 MB error after 0.37 s, 333 MB
fib(27) 0.49 s 0.41 s

After this change, no step of the interpreter uses native recursion on the depth of the calls or on the size of a value. The recursion that remains is on the nesting of the source text (lower, the patterns of a match), as in the parser and in the type checker.

Test plan

  • New unit test a_long_list_value_uses_no_native_stack: a list value of 1000000 elements is copied, printed, typed and freed on the 2 MB stack of a test thread.
  • New unit test a_value_prints_each_of_its_parts: the printed form of each kind of value.
  • New CLI test the_interpreter_runs_a_long_list (recursive_types.rs): a program builds a list of 100000 elements and gets its length.
  • cargo test --workspace after the merge of main: 650 pass, 0 fail. cargo fmt --check and cargo clippy --workspace --all-targets -- -D warnings are clean.
  • Probe battery: 51 SAME, 0 CHANGED, 7 SKIP (no wasmtime CLI in the session).
  • run --interp on each of the 149 .eph files of the repository gives the same output and exit code as before the two pull requests.

🤖 Generated with Claude Code

https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u

The interpreter was a recursive tree walker: each call of the program
used native stack (51 KB in a debug build, 9 KB in a release build), and
the process aborted at a call depth of 161 (debug) or 907 (release) on a
stack of 8 MB. Wasm reaches 16250 and gives a named trap.

`eval` is now a loop with a stack of frames on the heap (owner decision
2026-11-10). An expression is lowered 1 time to code with shared parts
(`Node`), so a frame can hold the part that runs next. A call uses no
native stack:

- 100000 calls deep give a value (0.14 s in a release build).
- Calls with no end give the runtime error "call stack exhausted" at
  1000000 frames, with exit code 1. The process does not abort.

The behaviour of each form is the same, with the same order of
evaluation and the same errors. An error leaves the environment as the
recursive evaluator left it. A top-level function is lowered when its
module is loaded, not copied at each call: `fib(27)` runs in 0.45 s, was
0.61 s.

`Value::Closure` holds its body as `Code` (an opaque shared pointer),
not as an `Expr`. No other crate reads that field.

Not in this change: a `Value` still copies, prints and frees its parts
with native recursion, so a program that builds a list of more than
about 1000 elements can still abort. That is the next pull request.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u
A `Value` held its parts in boxes, and it copied, printed and freed them
with native recursion. A list is a chain of 2 parts for each element, so
a long list overflowed the native stack: the copy at 1000 (debug) to
6700 (release) elements, the print at 800 to 18000, and the free at
10000 to 64000. Each read of a variable copied the value, so a function
on a list of n elements needed time and memory of the order n * n: a
list of 2000 elements took 2.95 s and 3 GB.

The parts of a value are now shared pointers (`Arc`):

- A copy is 1 step, for each size of value.
- `Drop for Value` frees the parts with a loop.
- `Display` prints with a loop. `Debug` is the printed form.
- `is_linear` uses a loop. `to_type` gives the type to 64 levels of
  parts, and the placeholder `()` below that.
- A closure is `Value::Closure(Arc<Closure>)`.

Measured in a release build: a list of 2000 elements takes 0.01 s and
12 MB; a list of 100000 elements takes 0.33 s and 179 MB; `fib(27)`
takes 0.41 s (0.49 s before this change, 0.61 s with the recursive
evaluator).

With the evaluator of the pull request before this one, no step of the
interpreter uses native recursion on the depth of the calls or on the
size of a value. The recursion that remains is on the nesting of the
source text (`lower`, the patterns of a `match`), as in the parser and
in the type checker.

No crate outside the interpreter builds or takes apart a value with
parts.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u
@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 9 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: bba5f4d3-d401-42a3-9560-6b5f0d3b67c9
Commits

Reviewing files that changed from the base of the PR and between dce26c1 and 1fe317c.

Files selected for processing: 3

  • TEST-NEEDS.adoc
  • src/ephapax-cli/tests/recursive_types.rs
  • src/ephapax-interp/src/lib.rs
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

After an error, the environment stayed as it was at the error: the
names of the call that failed stayed bound, and a region stayed active.
The recursive evaluator did the same, but calls with no end are now an
error that a caller can catch (the REPL), so the state is visible.

An error now ends each frame, the top one first: the names, the
environment and the regions are as they were before the evaluation.
`Interpreter::call` also restores its environment after an error.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u
…ge-pass-ow72tj-values

# Conflicts:
#	src/ephapax-interp/src/lib.rs
A call in tail position found a frame on top that gives the environment
back already. The call now adds no frame of its own, so a loop written
as a tail call has no depth limit: 1200000 calls run in 15 MB.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136P7ZmKrYLDC4WHi2yt11u
…ge-pass-ow72tj-values

# Conflicts:
#	src/ephapax-interp/src/lib.rs
Base automatically changed from claude/e2e-language-pass-ow72tj to main November 10, 2026 23:12
…ass-ow72tj-values

# Conflicts:
#	TEST-NEEDS.adoc
#	src/ephapax-interp/src/lib.rs
@sonarqubecloud

Copy link
Copy Markdown

@hyperpolymath
hyperpolymath marked this pull request as ready for review November 10, 2026 23:38
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ❌ Failed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-10T23:45:15.605398Z 1fe317c Draft marked ready
🔒 Security Review ❌ Failed 2026-11-10T23:39:02.580028Z
We couldn’t complete this review. Comment @codex security review to try again.
1fe317c Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@hyperpolymath
hyperpolymath merged commit 6724421 into main Nov 10, 2026
48 checks passed
@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has exhausted its Codex allowance and has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@hyperpolymath
hyperpolymath deleted the claude/e2e-language-pass-ow72tj-values branch November 10, 2026 23:39
@chatgpt-codex-connector

Copy link
Copy Markdown

This security review couldn’t start because the paying account has exhausted its Codex allowance and has insufficient available credits. The payer can check their balance and add credits in the Codex usage dashboard, then try again. If you do not manage the paying account, contact this repository's admins.

@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: 1fe317ca83

ℹ️ 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".

// Strings are linear resources
Value::String { .. } => return true,
// Pairs are linear if either component is linear
Value::Pair(l, r) => work.extend([&**l, &**r]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deduplicate shared nodes while checking linearity

When --unstable-core code repeatedly copies an unrestricted recursive value—for example, each iteration replacing a Tree with a node containing the two results of copy(tree)—the new Arc representation stores an O(n)-sized DAG, but this traversal pushes both aliases independently. Consequently, the linearity check before the next copy revisits the shared subtree along every logical path and grows exponentially (roughly 2^n), so a small valid program can stall even though this change makes the value itself cheap to copy. Track visited Arc allocations, or determine linearity from the declared type, so shared subgraphs are scanned once.

Useful? React with 👍 / 👎.

Ok(Value::String {
data: d1 + &d2,
region: r1,
data: format!("{d1}{d2}"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the left string allocation during concatenation

When a program builds a string through repeated appends, format!("{d1}{d2}") allocates a fresh buffer and recopies the entire accumulated left operand on every iteration. The previous d1 + &d2 consumed and extended the left String, allowing its spare capacity to be reused, so this change turns common incremental concatenation from amortized linear work into quadratic copying and increases peak memory. Extract the owned left string from left_val and append to it rather than formatting both borrowed operands into a new allocation.

Useful? React with 👍 / 👎.

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