Skip to content

Lazy if, eval budget, compile-time registration checks, Identifiers(), and opt-in helper groups#25

Merged
myzie merged 1 commit into
mainfrom
claude/serene-feynman-xd54f4
Jun 10, 2026
Merged

Lazy if, eval budget, compile-time registration checks, Identifiers(), and opt-in helper groups#25
myzie merged 1 commit into
mainfrom
claude/serene-feynman-xd54f4

Conversation

@myzie

@myzie myzie commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Implements the highest-value items from the post-review improvement list (#24 follow-up discussion), focused on reliability, robustness, and ergonomics. Everything stays backwards compatible except where the old behavior was itself the bug (details below).

What's in here

if(cond, then, else) is now a lazy special form

The eager if builtin evaluated both branches, so the natural guard idiom failed:

if(n != 0, total/n, 0)   →  division by zero error   (before)
if(n != 0, total/n, 0)   →  0                        (now)

if moves out of Builtins() and into the special-form machinery alongside map/filter/try, so only the selected branch evaluates. It is now always available (no WithBuiltins needed) and shadowable through the usual env→funcs rules — a user-registered if still wins and restores eager calling. The dispatch path for keyword-backed forms (map, if) was unified so shadowing always checks the user-visible name.

Compatibility: only observable when the untaken branch would have errored — exactly the case users want fixed — or when calling if without WithBuiltins, which previously errored and now works.

WithEvalBudget(n): deterministic work bound

MaxSourceLength and MaxEvalDepth bound memory and stack but not CPU: map(xs, map(xs, map(xs, it))) over a 10k-element env list is 10¹² predicate evaluations from a ~30-byte expression, and a context deadline still lets it burn a core for the full timeout. The budget counts one unit per AST node evaluated (including each per-element predicate re-evaluation) and fails the Run with ErrEvaluate: evaluation budget exceeded the moment it's exhausted. Each Run gets the full budget via a shallow Program copy, so concurrent Runs never share a counter, and try(...) can't catch its way past exhaustion. Default unlimited; zero hot-path cost when unused (one nil check per node).

Compile-time registration validation

WithFunctions previously swallowed prepareFunc errors, so {"f": 42}, nil entries, and bad signatures (three returns, second return not error) compiled fine and failed only when called. Compile now collects and returns these wrapped in ErrCompile — load-time failure is the point of the Compile/Run split.

Compatibility note: this is the one deliberate tightening. Registrations that could never be called successfully now fail at Compile instead of at call time, which also closes the undocumented loophole of registering non-function constants via WithFunctions (constants belong in the env, which is unchanged).

Program.Identifiers()

Sorted, deduplicated set of env-resolved names the expression references, computed once at compile. Excludes literals, it/index where an iterating form binds them, registered function names, and special-form names in call position — so hosts can validate expressions against a known env shape at load time ("strict mode" for free), track dependencies, or invalidate caches. Statically shadowing-aware where possible (a registered filter disables the it binding in the walker too); the unavoidable env-shadowing corner is documented.

Panic recovery on every reflect dispatch path

Bound methods already recovered runtime.Error panics; registered functions and env-stored callables didn't, so one buggy callee took down the host through Run. The same recover wrapper now covers all three reflect dispatch paths. Deliberate panic(v) with non-runtime values still propagates.

Opt-in builtin groups

Builtins() stays minimal so the sandbox story stays clean; the most-requested helpers ship as opt-in sets that every host previously re-implemented slightly differently:

  • expr.MathFuncs()min, max (variadic, int64-preserving), abs (MinInt64-checked), floor, ceil, round
  • expr.StringFuncs()trim, split, join, replace, startsWith, endsWith
  • expr.CollectionFuncs()first, last, sum (overflow-checked), slice(xs, i, j) (covers the rejected xs[i:j] syntax; negative indices, clamping, runes for strings)

All pure, deterministic, allocation bounded by input size.

CI: fuzz smoke

15s runs of all six fuzz targets on every push/PR — the rewriter bugs fixed in #24 were exactly the kind of thing seeds alone missed.

Docs

Spec (if moved to special forms, new groups section, budget under limits, registration validation), sandboxing/registering-functions/templates/higher-order-patterns guides, llms.txt, README, and the sandboxing example updated in the same change, with new docs-honesty tests (TestGuide_*, TestDocsExample10_*) pinning the changed claims per the repo convention.

Testing

  • go build ./..., go vet ./..., gofmt clean
  • go test -race -count=1 ./... passes (existing if-eagerness and call-time-signature tests updated to the new intended semantics)
  • New suites: budget_test.go, identifiers_test.go, builtin_groups_test.go, registration_test.go
  • 10s FuzzEval smoke run passed locally

https://claude.ai/code/session_01Nw3onLb6YHSRU1kjnKdaph


Generated by Claude Code

…rs, and helper groups

A batch of reliability and ergonomics improvements:

- `if(cond, then, else)` is now a lazy special form instead of an eager
  builtin: only the branch the condition selects evaluates, so guard
  idioms like `if(n != 0, total/n, 0)` work. It is always available
  (no WithBuiltins needed) and shadowable like every other form.
- `WithEvalBudget(n)` bounds the AST nodes a single Run may evaluate,
  giving hostile nesting (`map(xs, map(xs, map(xs, it)))`) a
  deterministic, cheap failure instead of burning a core until the
  context deadline.
- `WithFunctions` registrations are validated at Compile time: nil
  entries, non-function values, and unsupported signatures fail with
  ErrCompile instead of hiding until the first call.
- `Program.Identifiers()` exposes the sorted set of env-resolved names
  the expression references, for load-time env validation and
  dependency tracking.
- Runtime-error panic recovery now covers every reflect dispatch path
  (registered functions and env callables, not just bound methods).
- New opt-in builtin groups kept out of the default sandbox surface:
  MathFuncs (min, max, abs, floor, ceil, round), StringFuncs (trim,
  split, join, replace, startsWith, endsWith), and CollectionFuncs
  (first, last, sum, slice).
- CI gains a short fuzz smoke step over all six fuzz targets.

Docs (spec, guides, llms.txt, README) updated in lockstep, with new
docs-honesty tests pinning the changed claims.

https://claude.ai/code/session_01Nw3onLb6YHSRU1kjnKdaph
@myzie myzie merged commit 4c2cba4 into main Jun 10, 2026
1 check passed
@myzie myzie deleted the claude/serene-feynman-xd54f4 branch June 10, 2026 12:22
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