Skip to content

front-door/eshkol: admit exact rationals, IEEE doubles, and forward-mode AD - #1

Merged
RichardHoekstra merged 8 commits into
RichardHoekstra:mainfrom
tsotchke:eshkol/dialect-rational-float-ad
Sep 25, 2026
Merged

RichardHoekstra merged 8 commits into
RichardHoekstra:mainfrom
tsotchke:eshkol/dialect-rational-float-ad

Conversation

@tsotchke

@tsotchke tsotchke commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Extends the Eshkol adapter's admitted dialect beyond exact integers, as docs/interop.md listed as out of scope.

  • Exact rationals: :int promotes into :rational; Eshkol's numeric tower keeps them exact, and the direct evaluator and kernel VM agree.
  • IEEE doubles under an explicit ulp regime, stated per comparison the way the existing float regimes are.
  • Forward-mode AD via Eshkol's own derivative and gradient: the direct evaluator gains a dual-number evaluator, the kernel VM an independently written dual-number oracle, and Eshkol JIT/AOT are the systems under test.

The existing integer dialect is untouched; rational and float never mix implicitly. Tests cover each addition positively (rational exactness, a float sum at the stated bound, a derivative against both the dual evaluator and a central difference, a gradient component) and negatively (zero exact denominator, mixed exact/inexact operands, out-of-range gradient index). tools/test-system front-door/tests reports 45 assertions, 0 failures before and after. The four positive cases were also checked by hand against eshkol-run 1.3.5 (JIT and AOT).

One note: the shared harness wraps Eshkol launches in prlimit, which is Linux-only; on macOS the new tests fall back to direct invocation rather than skipping. That gap predates this change.

tsotchke and others added 6 commits September 6, 2026 12:55
…ward-mode AD

Why: docs/interop.md stated rational/float/AD were outside the front-door/eshkol
gate. Add a second, independently admitted numeric dialect beside the existing
exact-integer one: a grammar/type-check step where :int promotes freely into
:rational or :float but the two never implicitly mix, a direct evaluator and
an independently written kernel-VM oracle both lifted to dual numbers for
derivative/gradient, and an Eshkol emitter/JIT/AOT gate that lowers to
Eshkol's own derivative/gradient builtins. The exact-integer dialect in
eshkol.lisp is untouched.
Why: exercise the new dialect the way eshkol-tests.lisp already exercises the
integer one. Direct-vs-kernel agreement for rational exactness, a float sum
checked under the stated ulp regime, a derivative checked against both the
dual evaluator and an independent central-difference approximation, a
gradient component, one negative case per addition (a literal zero exact
denominator, a mixed exact/inexact operand pair, an out-of-range gradient
component index), and emission sanity. The opt-in real-Eshkol rung runs the
full JIT/AOT gate where ROSETTE_ISOLATED_WORKER's prlimit wrapper is
available and falls back to a direct invocation of the real compiler where it
is not, rather than silently skipping.
Why: interop.md said rational/float AD were outside the gate; state the
admitted promotion lattice (:int into :rational or :float, never :rational
into :float) and the explicit ulp comparison regime for :float readouts, and
note the CHANGELOG entry in the repo's existing voice.
Eshkol displays (+ 0.1 0.2) as 0.3, and the worker's reader turns that into
a SINGLE-FLOAT, so every real :float readout was refused at JIT. Reading it
as a double would not be enough: 0.3d0 is one ulp from the computed value and
passes the 2-ulp regime. Eshkol's INEXACT->EXACT cannot carry it either -- it
converts over a fixed 2^52 denominator (0.1 -> 450359962737049/2^52).

The emitted program now scales |x| by powers of two into an integer mantissa
and prints (:dyadic m e); the gate decodes it, refuses decimal literals and
non-finite values, and checks the receipt's :value-type against the type MAIN
was admitted at. Real Eshkol JIT/AOT now match the direct evaluator
bit-for-bit on 0.1+0.2, 1/3, a subnormal, and a negative value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rting

An ESHKOL-BACKEND-ERROR in one real-Eshkol case aborted the whole suite, so
later checks never ran. Each real case now reports its stage/status and counts
as one FAIL. Adds offline checks for the exact float readout, a float case that
needs all 53 bits (1/3), and passes the container client policy to the numeric
worker rung as the integer rung already does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tools/test-system front-door/tests printed 45 assertions and never loaded
front-door/eshkol/tests, which is easy to read as full coverage. It now lists
the suites it ran and names any suites of the same primary system it skipped.
CONTRIBUTING documents the real-Eshkol opt-ins and the prlimit requirement.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@RichardHoekstra RichardHoekstra left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you, and sorry it took us a couple of weeks to get to this. This is the first
outside PR on rosette-wire, and it's a really good one. It extends the gate exactly
the way we'd want: the numeric dialect sits beside the integer one rather than inside
it, the no-implicit-exact/inexact-mixing rule is the right call, and every addition
comes with a refusal test. It also closes an item docs/interop.md listed as out of
scope.

We ran it end-to-end on Linux (aarch64, eshkol:gb10-run container, Eshkol 1.3.3,
via ROSETTE_ESHKOL_CONTAINER):

Run Result
tools/test-system front-door/eshkol/tests, no Eshkol 37 passes, 0 failures (all 14 new checks pass)
Same, with real Eshkol integer four-way gate ✅, rational through the worker ✅, float ❌ MALFORMED-RECEIPT at JIT

The float failure was our infrastructure, not your code, so we've fixed it, and
you had no way to see it: the worker path needs prlimit, which macOS doesn't have.
Your CI run was also sitting on "approve workflow" for first-time contributors, and
we hadn't clicked it. Sorry about that.

What we pushed to this branch (3 commits on top of yours)

  1. fix(front-door-eshkol): read :float receipts as the exact double. The
    worker read Eshkol's 0.3 as a SINGLE-FLOAT, so every real float receipt was
    refused. Reading it as a double wouldn't have been enough: display rounds
    0.1+0.2 to 0.3, which is 1 ulp off and would pass the 2-ulp regime. The
    emitted program now scales the double by powers of two and prints (:dyadic m e)
    with an integer mantissa. The gate decodes that, refuses decimal and non-finite
    readouts, and checks :value-type against the type main was admitted at.
    Real JIT/AOT now match your direct evaluator bit-for-bit on 0.1+0.2, 1/3,
    a subnormal, and a negative value. If you put the old decimal readout back, it's
    refused.
  2. test(front-door-eshkol): count real-toolchain failures instead of aborting.
    One ESHKOL-BACKEND-ERROR used to abort the whole suite; now it's one FAIL
    with its stage. This commit also adds offline checks for the readout and a
    1/3 case that needs all 53 bits.
  3. tools(test-system): report which test suites ran. tools/test-system front-door/tests quietly skipped the Eshkol suite. Now it prints what ran and
    what it skipped. CONTRIBUTING documents the real-Eshkol opt-ins and prlimit.

With these, on Linux against real Eshkol (1.3.3, container):
tools/test-system front-door gives 45/0 plus 55/0 for the Eshkol suite,
and make contributor-check passes.

Something you may want on the Eshkol side

While building the readout we found that inexact->exact converts over a fixed
2^52 denominator in 1.3.3: (inexact->exact 0.1) gives
450359962737049/4503599627370496 (the exact value is 3602879701896397/2^55),
and (inexact->exact 1e-300) gives 0. R7RS asks for the exact equivalent. Also,
display prints doubles with fewer than round-trip digits (0.1+0.2 gives 0.3,
1/3. gives 0.333333). Happy to open an issue with the repro if that's useful,
or maybe it's already different in 1.3.5?

Small things on the PR itself

None of these block merging. If you'd rather we fold them in, just say so.

  • The direct-invocation fallback parses the whole receipt line (inline comment on the fallback).
  • numeric-program-eval drops funs when it re-parses (inline comment on numeric-program-eval).
  • A derivative test through / would give the quotient rule an independent check (inline comment on the kernel oracle).

Nits (take or leave)

  • Only a literal 0 denominator is refused. (/ 1 (- 1 1)) still reaches a raw CL
    division-by-zero. Fine as documented behaviour if you prefer.
  • +default-ulp-bound+ is a defparameter. Maybe defconstant?
  • %format-double's docstring mentions .scratch/, which isn't in the tree.
  • prlimit-available-p accepts any exit code. zerop is probably what's meant.

🤖 Generated with Claude Code

Comment on lines +550 to +557
(value-line (car (last (remove "" (uiop:split-string output :separator '(#\Newline))
:test #'string=)))))
(ignore-errors (delete-file path))
(format t "~& INFO ~A => ~A~%" (getf case :name) (string-trim '(#\Space) (or value-line "")))
(check-anon (format nil "real-eshkol-numeric-direct-~A" (getf case :name))
(let ((*read-default-float-format* 'double-float))
(handler-case
(= (read-from-string (string-trim '(#\Space) value-line))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

value-line is the last non-empty stdout line, which is the whole receipt:
ROSETTE-FRONT-DOOR-ESHKOL-NUMERIC-RESULT (:schema … :value …). So
read-from-string returns the marker symbol and the check fails. Taking the text
after +numeric-receipt-marker+ and decoding :value from the plist should fix it.
There's now esh::%numeric-accept-result, which handles the new (:dyadic m e)
float encoding, so the fallback can reuse it. We couldn't run this branch on Linux,
so this comes from the receipt shape we observed, not from an actual run.

(t (%numeric-refuse :eval term "outside the numeric dialect")))))
(t (%numeric-refuse :eval term "not a numeric-dialect term"))))

(defun numeric-program-eval (program)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This re-parses only (main-numeric …), so a main that calls
derivative/gradient is refused with "call to undefined function". Skipping the
re-parse (the program is already admitted) or passing the defun-numeric forms
along should fix it.

(eval-numeric-term (numeric-program-main program) '() (numeric-program-funs program)))

;;; -------------------------------------------------------------------------
;;; Coincidence 2: the kernel-VM oracle, written independently.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The kernel walk and dual*/dual/ share the same product and quotient rules, so the
central difference is the independent derivative check, and it only covers cube.
A case like x/(x+1) against the central difference would cover the quotient rule.
Minor: the header mentions (:INT n)/(:RAT …)/(:FLT d) tags, but only duals are
tagged. Maybe adjust the comment?

@tsotchke

Copy link
Copy Markdown
Contributor Author

Thank you for the careful review, and for running it end to end on Linux — the dyadic readout is a better design than what I had, and catching the display rounding hiding a 1-ulp error is exactly the kind of thing this gate exists for.

On the Eshkol side, both are fixed in 1.3.5, which is about to ship: inexact->exact now uses the exact binary decomposition ((inexact->exact 0.1) is 3602879701896397/36028797018963968, and 1e-300 converts exactly and round-trips), and display prints shortest round-trip doubles (0.30000000000000004, 0.3333333333333333). Same results on the native compiler and the bytecode VM.

I'll push fixes for the three inline points (the fallback receipt parse, funs in numeric-program-eval, and a quotient-rule derivative test), plus the zerop check and the stale docstring. For computed zero denominators I'd rather refuse them than let CL's division-by-zero escape, so I'll add that with a test.

…computed zero denominators

numeric-program-eval re-checked only MAIN-NUMERIC, so a MAIN that calls
DERIVATIVE or GRADIENT on a DEFUN-NUMERIC was refused as an undefined
function. It now re-checks the whole program.

A zero denominator the term computes, as in (/ 1 (- 1 1)), reached CL's
DIVISION-BY-ZERO. The direct evaluator and the kernel walk now refuse it
the same way the literal case is refused at type-check.

Also: +default-ulp-bound+ is a defconstant, the kernel walk's header
describes the values it actually walks, %format-double's docstring no
longer cites an untracked directory, and the readout comments say which
Eshkol releases print and convert doubles inexactly.
…the quotient rule and computed zeros

The macOS direct-invocation fallback read the whole receipt line with
READ-FROM-STRING and so compared the marker symbol. It now reads the plist
after the marker and decodes :VALUE with %decode-numeric-readout, comparing
floats under the ulp regime.

Adds a derivative of x/(x+1) checked against a central difference, which is
independent of the quotient rule the two evaluators share; a check that
numeric-program-eval keeps DEFUN-NUMERIC helpers; and refusal checks for
computed exact and inexact zero denominators in both evaluators.
prlimit-available-p now requires a zero exit status.
@tsotchke

Copy link
Copy Markdown
Contributor Author

Pushed two commits on top of yours:

  • fd7c7c9 fix: numeric-program-eval re-checks the whole program, so main can call derivative/gradient on a defun-numeric; a computed zero denominator ((/ 1 (- 1 1)), and the inexact (/ 1.0 (- 1.0 1.0))) is refused by both the direct evaluator and the kernel walk instead of reaching CL's division-by-zero. Also defconstant for the ulp bound, the kernel header comment, the %format-double docstring, and the readout comments now say which Eshkol releases print and convert doubles inexactly.
  • 0d46699 test: the direct-invocation fallback reads the plist after the marker and decodes :value with %decode-numeric-readout (floats compared under the ulp regime); a derivative of x/(x+1) against a central difference; a check that numeric-program-eval keeps helpers; computed-zero refusals for both evaluators; prlimit-available-p requires exit status 0.

Checked on macOS against eshkol-run 1.3.5: the new checks pass, including the fallback path on a float sum, a rational, a derivative, and the quotient-rule derivative (all four decode through the dyadic readout). front-door/tests is still 45/0.

One thing I couldn't do from macOS: the full front-door/eshkol/tests suite stops at the fake-runner gate, which always goes through the prlimit worker. That was already the case before these commits, so your Linux CI run is the one that exercises it end to end.

@tsotchke

Copy link
Copy Markdown
Contributor Author

Update: ran the full suite on Linux (x86_64, with prlimit) at 0d46699, against eshkol-run 1.3.5 via ROSETTE_ESHKOL_BIN:

  • front-door/tests: 45 passes, 0 failures
  • front-door/eshkol/tests: 61 passes, 0 failures, including the worker-path numeric runs (rational exactness, float ulp bound, float third, derivative, gradient)

Without Eshkol the Eshkol suite is 50/0 on both x86_64 and aarch64.

@RichardHoekstra

Copy link
Copy Markdown
Owner

Thanks! You fixed every point, nits included, and the Eshkol-side fixes are great to hear about. Refusing a computed zero denominator is better than what I suggested. It also means the direct evaluator, the kernel walk and Eshkol can't disagree about a non-finite result, because none of them is allowed to produce one.

I read through fd7c7c9 and 0d46699. Two things I liked in particular: the refusal looks through the dual, so an AD path gets the same refusal, and the quotient-rule case works out exactly to 1/9, so it pins the rule tightly instead of just landing within a tolerance.

CI on 0d46699 is green (front-door 45/0, Eshkol suite 50/0 without Eshkol). We also reran the suite on our side, against Eshkol built from master (5961d2a, the 1.3.5 candidate) on aarch64:

  • tools/test-system front-door: front-door 45/0, front-door/eshkol/tests 61/0, including the worker-path rational, float ulp bound, float third, derivative and gradient runs. That matches your x86_64 number.
  • Directly on 1.3.5: (inexact->exact 0.1) is 3602879701896397/36028797018963968, 1e-300 round-trips, and display gives 0.30000000000000004 and 0.3333333333333333.

Merging now. Thanks again for the PR, and for the Eshkol fixes that came out of it.

@RichardHoekstra
RichardHoekstra merged commit b7f09b2 into RichardHoekstra:main Sep 25, 2026
1 check passed
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