Skip to content

Add an engine-free runtime for compiled JavaScript (@cortex-js/compute-engine/runtime) - #408

Closed
enumeratio wants to merge 6 commits into
cortex-js:mainfrom
enumeratio:js-runtime
Closed

enumeratio wants to merge 6 commits into
cortex-js:mainfrom
enumeratio:js-runtime

Conversation

@enumeratio

@enumeratio enumeratio commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Option (1) of #372, with the five conditions from the discussion.

createJavaScriptRuntime(options) returns the _SYS bundle that compile() builds for run(), with no engine behind it. The code of a JavaScriptTarget result reads only _SYS and _, so it can be stored and run on a page, in a worker or on a server: runtime.load(result) returns the function. The runtime type exposes only load, frame, iterationLimit, deadline and runtimeVersion; the helper table is not part of it and may change in any release.

import { createJavaScriptRuntime } from "@cortex-js/compute-engine/runtime";

const rt = createJavaScriptRuntime({ random: Math.random });
const f = rt.load(result); // result.code, result.preamble, result.calling
f({ x: 2 });

1. Seeded randomness. The runtime includes frameDraw and foldSeed, so WithRandomSeed inside compiled code gives the interpreter's values. frame takes an outer frame ({ seedLo, seedHi, next } or { seed, next }, also when set on the runtime), and runtime.frame.next is the advanced counter after the call. random is used only for draws outside a frame and for the integrals' Monte-Carlo samples (live inside a frame too); random: null denies, and a draw then throws the same CapabilityDeniedError('entropy') as the engine. The class differs in each bundle, so test e.name, not instanceof (documented).

2. Limits. iterationLimit (default 1024, the engine's; 0 or less means no cap, and rt.iterationLimit reads Infinity then, as ce.iterationLimit) and deadline (a Date.now() timestamp, off when unset) are options, and settable on the runtime. The lazy-stream helpers and the shuffle/choice loops read them at call time. The functions/imports limit is documented in docs/COMPILATION-MODEL.md and the CHANGELOG: those functions are copied as source, so a closure loses its scope and a named function must exist where the code runs.

3. Version. CompilationResult.runtimeVersion and runtime.runtimeVersion (also exported as runtimeVersion) are the package version. load() throws on a mismatch, and on code with no runtimeVersion. No helper-set hash.

4. One implementation. The static helpers moved out of compilation/javascript-target.ts into compilation/javascript-runtime.ts (and the jet arithmetic into compilation/jet-helpers.ts), with no engine import. The engine's makeSysHelpers is now makeSysHelpers(source), called with a RuntimeSource built from ce._liveRandom/ce._random, ce._randomFrame, ce.iterationLimit and ce._deadlineFrame; the runtime builds one from its options. nextFrameDraw and withSeedFrame (in numerics/random.ts) are shared by ce._random(), withRandomSeedFrame and the runtime. run.SYS is unchanged. The entry point is built as esm-min/runtime.js and umd-min/runtime.cjs, in exports, and in the nodenext smoke test; a built-bundle smoke test (npm run test:js-runtime) runs in the production build.

Repeated calls. load() builds the same two-stage function as run(): the definitions that read nothing per call (a constant list, a memo) are evaluated once, the rest on every call. The result carries the split as preambleOnce, preamblePerCall and, for a lambda, callCode (preamble and code are unchanged); stored code without them runs its preamble per call as before. A test counts the constructions of a 1,000-element list over many calls: once with load(), once per call for a legacy result.

5. Size and tests.

entry minified gzip
dist/esm-min/runtime.js 273,716 B 99,695 B
dist/umd-min/runtime.cjs 274,673 B 100,096 B

It still pulls in the numerics the helpers call: @arnog/colors (~38 KB minified), numeric-complex (~36 KB), special-functions (~25 KB), BigDecimal and its transcendentals (~36 KB together) and lerch-phi (~13 KB). It has no boxed expression, library, LaTeX or compiler code. Splitting the colour and special-function helpers behind their own entry would shrink it; not done here.

test/compute-engine/js-runtime.test.ts runs the same seeded programs (Random, several draws, RandomShuffle, RandomChoice, RandomSample, a nested frame) interpreted, with run() and as stored .code on the runtime, with numeric and string seeds, and checks the outer-frame round trip, random: null, the limits, the version check, and stored expressions and lambdas. A bundle test builds src/runtime.ts with esbuild, asserts that every input is on an allow-list of runtime and numerics paths, and runs the bundle. The built-bundle smoke test also checks that the build replaced the version placeholder.

Fix #372

…ute-engine/runtime, built from the factory the engine's own _SYS uses; seeded frames, random source, iteration limit and deadline are options; add runtimeVersion to compilation results; move the static helpers into modules with no engine import; add a three-way seeded test and a bundle test. Fix cortex-js#372
# Conflicts:
#	src/compute-engine/compilation/javascript-target.ts

@arnog arnog left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, this meets the five conditions from #372. The move is clean: an AST comparison found the 846 moved declarations and all 209 helpers unchanged. The seeded runs agree three ways. Requested changes:

  1. Rebase onto current main. Since your last merge of main, ~175 lines changed inside the code this PR moves: 30 changed helpers (pow, cpow, the complex trig and inverse trig helpers, csqrt, cexp, cln, …), 4 new ones (sinpi, cospi, tanpi, cexppi), and chopKernelDust was removed. Git will not prompt you to port these into the new javascript-runtime.ts. If the four new helpers are missed, the type check still passes, but compiled code throws at run time. Please extract the helpers again from the current javascript-target.ts.
  2. load() runs the whole preamble on every call. run() runs the definitions that do not read _ only once, but load() rebuilds constant lists and resets the memos on each call. We measured 6× slower for At(L, Floor(x)) and 2.6× for Max(L) + x with a 1,000-element L. Per-pixel sampling is the use case, so please store the once-only and per-call parts of the preamble on the result, and have load() build the two-stage function.
  3. Narrow the published type. JavaScriptRuntime should expose only load, frame, iterationLimit, deadline and runtimeVersion, with the helper table opaque. The helpers must be free to change in any release.
  4. "No engine module" test: please use an allow-list of input paths (or an import/no-restricted-paths zone in .eslintrc.cjs) instead of a list of forbidden paths.
  5. Smaller items:
    • The type of frame should accept { seed, next }.
    • rt.iterationLimit should return Infinity for a setting of 0 or less, as ce.iterationLimit does.
    • The smoke test should check that the version string was replaced by the build, not only that the two sides are equal.
    • Please document that CapabilityDeniedError must be tested with e.name, because it is a different class in each bundle.
    • Optionally, load() could reject code that has no runtimeVersion.

…ers into javascript-runtime.ts (30 changed, 4 new, chopKernelDust removed); move the negative-base real-power branch decision to numerics/real-power.ts so the runtime stays engine-free
…e function run() uses (preambleOnce/preamblePerCall/callCode on the result), the published JavaScriptRuntime type exposes only load/frame/iterationLimit/deadline/runtimeVersion, iterationLimit reads Infinity for 0 or less, load() requires runtimeVersion, the bundle test uses an allow-list, the smoke test checks the version was replaced; CHANGELOG entry back under Unreleased. Fix cortex-js#372
@enumeratio

Copy link
Copy Markdown
Contributor Author

What changed, per item:

  1. Rebase: main is merged rather than rebased. The static helpers are re-extracted from current javascript-target.ts into javascript-runtime.ts: by an AST comparison of top-level declarations and SYS_HELPERS properties, all 211 helpers on main (30 changed, 4 new: sinpi, cospi, tanpi, cexppi) are identical to ours, none missing and none extra (chopKernelDust is gone), and the other 7 differing declarations are the factories and target classes that stay split. pow and cpow now call negativeBaseRealPow, so its branch decision moved to numerics/real-power.ts (engine-free), with constant-folding.ts and arithmetic-power.ts using it from there.
  2. load() builds the two-stage function run() uses. The result carries preambleOnce, preamblePerCall and, for a lambda, callCode. A test counts the constructions of a 1,000-element list across calls: once, versus once per call for stored code without the split.
  3. JavaScriptRuntime is now load, frame, iterationLimit, deadline and runtimeVersion; the helper table is internal.
  4. The bundle test uses an allow-list of input paths.
  5. frame accepts { seed, next } when set; iterationLimit reads Infinity for 0 or less; the smoke test checks the version placeholder was replaced; CapabilityDeniedError and the e.name check are documented; load() rejects code without runtimeVersion.

The CHANGELOG entry stays under Unreleased and is updated. Size is in the description.

@arnog arnog left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks — all five items are done, and we verified them: the re-extraction matches current main exactly (851 declarations and 213 helpers identical), load() is now as fast as run(), the type is narrow, and the bundle test uses an allow-list. Typecheck, madge and 226 related suites pass. Two places remain where loaded code disagrees with run():

  1. Input conversions. load() calls the generated function directly and skips the entry conversions that run() applies. With z declared complex, z^2 + z gives 6 from run({z: 2}) but {re: NaN, im: NaN} from load(result)({z: 2}). A Float64Array for a list variable gives 6 from run() and null from load(). Please store the engine-independent part of the conversion plan with the result and apply it in load().
  2. Reconstruction precision for a negative base. The /runtime bundle has its own BigDecimal (precision 50), so realPowerReconstructionDigits() always picks 17 there, while a machine-precision engine picks 15. With ce.precision = 'machine', (-2)^x at x = 33.3333333333333 gives 10822639409.68 from run() and NaN from the bundled runtime. Please carry the digit count with the result (the Python target already bakes it into _ce_pow), or take it as a runtime option. docs/COMPILATION-MODEL.md should list it with tolerance and angular unit as fixed at compile time. Tests in jest miss this because jest shares one BigDecimal between engine and runtime; a check in the bundle smoke test would catch it.

Nits: rationalize and reducedRational are now unused in arithmetic-power.ts; and the getter and setter of JavaScriptRuntime.frame have different types, which needs TypeScript 5.1+. Please document that or use one type.

…the input conversions run() does (entryPlan on the result), the digits a negative base's float exponent is read to are fixed at compile time (reconstructionDigits) so the runtime's own precision no longer decides, the frame is set with setFrame(), drop the unused rationalize and reducedRational imports; the smoke test checks both on the built bundles. Fix cortex-js#372
@enumeratio

Copy link
Copy Markdown
Contributor Author

Thanks. All three are fixed, with tests.

  1. Input conversions: the result now carries the engine-free part of the entry plan (entryPlan, plain data, so it survives JSON storage) and load() applies it. z^2 + z at z = 2 is 6 from run() and load(), and so is a Float64Array for a list symbol; a complex value for a real symbol throws from both.
  2. Reconstruction precision: the digit count is baked into the result at compile time (reconstructionDigits) and load() reads the exponent of a negative base to that, not to the bundle's own precision; (-2)^x at x = 33.3333333333333 is the same from run() and the bundled runtime at machine precision. It is listed in docs/COMPILATION-MODEL.md with tolerance and angular unit, and the built-bundle smoke test checks it (and the conversions).
  3. Nits: rationalize and reducedRational imports removed from arithmetic-power.ts; frame is now a read-only property with a setFrame() method, so no TypeScript 5.1 requirement.

@arnog arnog closed this in c71298d Oct 5, 2026
@arnog

arnog commented Oct 5, 2026

Copy link
Copy Markdown
Member

Thanks — the second review's three items were all fixed, and we verified them, including in separately built bundles. Rather than ask for another merge with main, we finished it on our side and landed it as c71298d, with you as co-author. What we added on top of your branch:

  • Merge with current main. absAny and describeRuntimeValue (added on main after your last merge) moved to javascript-runtime.ts.
  • run() uses reconstructionDigits too. It read the working precision on each call, so code compiled at machine precision gave NaN for (-2)^x at x = 33.3333333333333 after ce.precision changed or another engine was constructed, while load() gave 10822639409.68. Both now use the count recorded at compile time.
  • load() limits a stored reconstructionDigits to 15–17, and the stored entry plan copies its arrays.
  • A loaded function called with no argument now throws the same TypeError as run() when the code reads a symbol; it returned NaN.
  • Tests for a nested call and for a throwing inner call, on both run() and load(); the Prettier error in arithmetic-power.ts; the CompilationResult doc comment re-attached to its type.

This will be in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

JS compile: a supported way to run compiled code ahead of time

2 participants