Commit graph

324 commits

Author SHA1 Message Date
Yogthos
f9a665b3c4 Chez Phase 3 inc 3: try/throw + host-call + regex/inst/uuid + def-meta in jolt.backend-scheme
Completes the op coverage of the portable Clojure emitter — it now handles every
op emit.janet does (const/local/var/the-var/host/host-static/host-new/if/do/
invoke/vector/set/map/quote/throw/try/regex/inst/uuid/host-call/let/loop/recur/
fn/def). Adds emit-try (guard + dynamic-wind), :throw, :regex/:inst/:uuid, and
:host-call (jolt-host-call for rt-shimmed methods else record-method-dispatch).

def-meta + quoted-symbol-meta needed emit-quoted to reconstruct plain jolt VALUES
(metadata maps), not just reader forms. The blocker was that :meta arrived as a
raw Janet table embedded in the IR — jolt's count/map?/keys don't work on a table
(counter to jolt.ir's 'no host values embedded'). Fixed at the host seam:
h-sym-meta now returns the meta as an immutable struct, which is a portable jolt
map (jolt count/map?/keys work on a struct, and the Janet backend's merge/get
still do too). emit-quoted handles both reader forms (jolt.host form-* contract)
and jolt-value collections (native map?/vector?/set?/seq? branches, after the
form-* branches so reader forms win).

Gate: emit-parity 55/55 (incl try/catch/finally, ^:private def-var-with-meta!
structural check, inst/uuid eq, regex smoke, quoted-sym-meta). Full jpm gate
green after clean rebuild (seed change). jolt-me6m.
2026-06-19 18:16:36 -04:00
Yogthos
109bfcd09d Chez Phase 2: clojure.set + clojure.math + pprint (jolt-j5vg, jolt-22vo)
Close the remaining Phase-2 stdlib parity gaps.

clojure.set: pure Clojure over core, so just added to the prelude stdlib tier
(driver.janet stdlib-ns-files + jolt-chez fingerprint), same as clojure.edn.

clojure.math: not a .clj on the seed (native math/ bindings via jolt-h79), so
Chez gets its own host/chez/math.ss def-var!ing each fn over Chez native flonum
math. The analyzer already knows the ns (api.janet install-clojure-math!), so
refs lower to var-deref. Also adds the missing 'long' coercion to converters.ss
(int's sibling; several math cases wrap in long).

clojure.pprint: dropped the 2-arity's (binding [*out* writer] ...). *out* isn't
a bindable var in jolt — printing routes through the host (dyn :out) seam, so
the binding never redirected anything; it only made the defn uncompilable, which
the seed tolerated via interpreter fallback. Chez has no fallback, so the whole
pprint defn died. Dropping it is behavior-preserving (writer was always ignored)
and lets pprint compile cleanly. Both corpus cases pass.

Corpus parity 2259 -> 2280, crashes 191 -> 170, 0 new divergences. Floor raised.
New unit test test/chez/_stdlib.janet (27/27). Full Janet gate green (147 files).
2026-06-19 17:06:26 -04:00
Yogthos
c70f3bae34 Chez Phase 2 (inc X): #inst / #uuid literals + java.time (jolt-at0a)
The analyzer lowers a #inst/#uuid tagged form to a :inst/:uuid IR leaf, mirroring
the existing :regex node: the Janet back end punts to the interpreter (its
data-readers parse the literal, so seed behavior is unchanged), the Chez back end
emits jolt-inst-from-string / jolt-uuid-from-string.

host/chez/inst-time.ss is the Chez-native value layer: a jinst record holding
epoch ms (RFC3339 parsed via Hinnant civil/days math, with Clojure's partial
defaults and +/-hh:mm offsets), wired into jolt-get (so the overlay inst?/inst-ms
read it), jolt= / jolt-hash (instant identity as a map key), pr-str (#inst
"...-00:00"), str, type, and instance? java.util.Date. The java.time surface
(DateTimeFormatter ofPattern/ISO_LOCAL_DATE_TIME/ofLocalized*, the pattern engine,
Instant, ZoneId, LocalDateTime, FormatStyle, Locale, Date) ports java_base.janet
over host-static.ss's registries.

Corpus 2202->2238, 0 new divergences; clears the whole 'unsupported form'
emit-fail bucket. Full Janet gate green (analyzer/backend changes are
behaviour-preserving — #inst still parses through the interpreter's data-readers
on the seed).
2026-06-19 11:05:22 -04:00
Yogthos
c90c4cb610 Chez Phase 2 (inc Q): Java class statics + constructors (jolt-avt6)
Lower host class interop on the Chez back end. The analyzer now turns a
non-var qualified ref `Class/member` into a :host-static node and a
`(Class. ...)` / `(new Class ...)` form into a :host-new node (ir.clj
gains both, with walker support). The Janet back end punts both to the
interpreter, so its behavior is unchanged (verified: dot-form, `..`
threading, shadowed `new`, and all interop still resolve via fallback).

The Chez emit lowers a value ref to host-static-ref, a call head to
host-static-call, and a constructor to host-new. host/chez/host-static.ss
is the runtime registry these resolve against — the Chez port of the
seed's class-statics / class-ctors / tagged-methods (java_base.janet +
host_io.janet), restricted to the java.lang/util/net/io surface portable
cljc code calls: Math, System (getenv/getProperty/exit/currentTimeMillis),
Long, Integer, Boolean, Character, String, Thread, Class, Pattern
(compile/quote/MULTILINE), URLEncoder/Decoder, Base64, the Number method
surface (byteValue/intValue/...), plus the StringBuilder, StringWriter,
StringReader, PushbackReader, HashMap, StringTokenizer, BigInteger,
String, MapEntry, and exception constructors. Constructed objects are
jhost records dispatched through record-method-dispatch.

Also: emit now evaluates collection-literal elements left-to-right
(emit-ordered) — Chez evaluates call args right-to-left, which had been
swapping side-effecting elements in [(read r) (read r)] and map literals.
This un-allowlisted the 6 eval-order corpus cases (the read-line trio +
the three map-construction cases). Removed `.write` from the
jolt-host-call fast-path so a StringWriter routes through dispatch.

java.time formatting, edn/read-over-readers, and slurp/with-open over
readers are deferred to a follow-up.

Corpus parity 2078 -> 2134 (floor raised), 0 new divergences; the
print-method builtin-override case is allowlisted (same multimethod gap,
newly reachable now that StringWriter constructs). emit-test 326/326,
_javastatic 51/51, conformance 355x3, full jpm test green.
2026-06-18 23:24:01 -04:00
Yogthos
6cc3dc2c7f Fix seed assoc! to throw on odd args (jolt-ea9k)
The transient assoc! accepted an odd key/val count and silently assigned
nil to the dangling key — non-Clojure, and inconsistent with the seed's
own plain assoc (which throws) and Clojure's assoc!. Make it throw.

Updates the 4 'assoc! odd args' transient spec rows to 3 :throws + 1
even-args positive, and regenerates corpus.edn. The Chez host already
threw on these, so this only realigns the corpus contract.
2026-06-18 03:27:21 -04:00
Yogthos
02139af0a1 Chez Phase 1 (increment 3q): multimethod dispatch + late-bind
host/chez/multimethods.ss implements the multimethod runtime: defmulti/defmethod
expand to defmulti-setup/defmethod-setup calls (+ get-method/methods/
remove-method/prefer-method/prefers). A jolt-multifn record carries its dispatch
fn and a jolt=-keyed method table; jolt-invoke dispatches it (exact match, then
isa?/hierarchy with prefer-method, then :default), reusing the overlay's
isa?/derive/make-hierarchy. The multifn's ns comes from a runtime chez-current-ns
(default user; the prelude load sets clojure.core for print-method/print-dup).

Two emit-side changes were needed:
- late-bind (:late-bind-unresolved? ctx flag, default OFF): defmulti expands to a
  bare-symbol setup call, so the analyzer doesn't intern the name and a forward
  reference '(area ...)' after '(defmulti area ...)' in one form was 'Unable to
  resolve symbol'. The strict compiler punts these to the interpreter; the Chez
  back end has none, so the flag lowers an unresolved symbol to a var-ref against
  the compile ns (open-world -e semantics). Set only by the Chez make-ctx /
  jolt-chez; the main compiler keeps strict resolution (host_iface late-bind?
  defaults nil).
- a :var call head now routes through jolt-invoke, since a late-bound var can hold
  a multifn (or keyword/coll IFn), not just a procedure. Transparent for
  procedures; the hot self-recursive call is a :local known-proc, stays direct.

Class-based dispatch ((class x)/String) deferred (needs deftype/class subsystem).

Parity 1506 -> 1530/2497, 0 new divergences. emit-test 302/302. Full janet gate
green (the analyzer flag is off there; suite flakiness under parallel load only).
2026-06-18 01:24:01 -04:00
Yogthos
37c433bd4a Chez Phase 1 (increment 3i): regex via vendored irregex
Closes the last clojure.core prelude emit gap (parse-uuid): the whole
non-macro core now lowers to Scheme (prelude reach 355/355).

A #"..." literal analyzes to a :regex IR node. The Chez back end emits
a jolt-regex value over irregex (Alex Shinn, BSD), vendored as the
vendor/irregex submodule -- a portable Scheme regex with PCRE/Java-style
string patterns and first-class Chez support. host/chez/regex.ss wraps
jolt's re-* surface over it: irregex-match -> re-matches (anchored),
irregex-search -> re-find, groups as Clojure [whole g1 ...] vectors,
re-seq as a jolt seq. re-pattern/re-matches/re-find/re-seq/regex? are
def-var!'d into clojure.core so prelude / -e code resolves them.

They stay OUT of the subset native-ops on purpose: irregex's
Unicode/property-class semantics differ from the seed's byte-PEG
approximation, so keeping them prelude-only avoids dragging
engine-difference divergences into the subset-parity corpus. The Janet
back end punts :regex to the interpreter (the seed compiles #"..." to a
Janet PEG), so the main language is unchanged.

Only two adaptations for Chez's top level: a cond-expand shim (Chez's is
library-only) and a normalizing error wrapper (silences irregex's 1-arg
error warnings). rt.ss load is ~0.18s.

emit-test 131/131 (regex literal + re-* parity vs the CLI oracle);
prelude reach 355/355; Chez subset 672/672, 0 divergences; full gate
green.
2026-06-17 19:44:18 -04:00
Yogthos
b1cdfd1c9b Chez Phase 1 (increment 3h): host-interop method-call emit
(.method target arg*) now analyzes to a :host-call IR node instead of
punting at analyze. The Chez back end lowers it to a jolt-host-call
dispatch for the methods the RT shims (.write -> port display,
.isDirectory -> file-directory?, .listFiles -> directory-list); any
other method stays out of subset (clean emit-time reject, so it can't
read as a compiled-but-broken corpus divergence). The Janet back end
punts ALL :host-call to the interpreter, same shape as letfn: compiles
on Chez, interprets on Janet, zero change to the main language.

Closes the io tier's print-method defmethods and file-seq: prelude emit
reach 348 -> 354/355 (50-io 20/20). The one remaining gap is the regex
literal in parse-uuid (needs a regex engine on Chez; deferred).

emit-test 122/122; Chez subset 672/672, 0 divergences; full gate green.
2026-06-17 18:58:44 -04:00
Yogthos
0f7d2753a8 Chez Phase 1 (increment 3g): letfn + declare/def-no-init
Closes the last two non-host-interop prelude emit gaps.

letfn now analyzes to a :let node flagged :letrec — the binding fns are bound
into the env together before any spec is analyzed, so siblings and self resolve.
The Chez back end lowers it to letrec*; the Janet back end punts it at emit
(its sequential let* can't express the mutual recursion — same interpreter
fallback as before, just decided at emit-ir instead of analyze).

(def x) with no init (declare) analyzes to a :def with :no-init instead of
punting. Chez reserves the var cell via declare-var! (which doesn't clobber an
existing root — (do (def x 7) (def x) x) => 7); the Janet back end still punts
to the interpreter, which interns a genuinely-unbound var.

fallback-zero-test now checks emit-ir too, not just analyze-form, so the real
compile-vs-interpret decision is what it asserts (letfn/def-no-init analyze but
the Janet back end punts them). letfn stays in must-punt with an updated note.

Prelude emit reach 342 -> 348/355 (40-lazy now 13/13); Chez subset 664 -> 672,
0 divergences; emit-test 110 -> 117. Full gate green.
2026-06-17 18:27:34 -04:00
Yogthos
d35783bf1b Reject a malformed catch clause with a clean error in both modes
jolt's catch is (catch class binding body*); the binding (3rd element) must be
a symbol. Neither the analyzer nor the interpreter validated it, so a non-symbol
binding crashed with an internal Janet error (expected integer key for array...)
and, in the interpreter, a malformed clause whose body never threw was silently
swallowed (returned the try value). Clojure rejects a non-class/non-symbol catch
clause; match that with an up-front error in analyze-try and eval-try.

Surfaced building the Chez try/throw emit. Regression rows in exceptions-spec
(runs x3 modes) plus a unit test asserting the clean message in interpret and
compile. jolt-kg6p.
2026-06-17 17:25:14 -04:00
Dmitri Sotnikov
3c853429f9
Clojure-compat fixes enabling external HTTP-client support (#159)
Generic language fixes so a host-shim library (jolt-lang/http-client) can carry
clj-http-lite without baking any HTTP/native code into core:

- reader: accept #^ (deprecated metadata reader macro) as ^
- (str pattern) returns the raw regex source, not #"..." — pattern composition
  via (re-pattern (str p ...)) now works
- into/conj onto a map merges map items (was (into {} [{:a 1}]) -> {nil nil})
- a Var is callable as its current value (e.g. (wrap #'f) threading a var client)
- core-class reads a :class field off a thrown table, so libraries can throw
  typed exceptions whose (class e) is a JVM class name
- compiled catch unwraps the interpreter's :jolt/exception envelope (__unwrap-ex),
  so a typed throw keeps its class/message across the interpret/compile boundary
- slurp accepts a byte-array; io/copy is generic over :jolt/input-stream /
  :jolt/output-stream stream markers
- instance? gains a registry (__register-instance-check!) for library shim types
- new clojure.test namespace (deftest/is/are/testing/use-fixtures/run-tests with
  class-aware thrown?/thrown-with-msg?)

Spec: test/spec/clojure-interop-fixes-spec.janet.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-17 06:42:52 +00:00
Dmitri Sotnikov
951a3000e6
Fix five bugs surfaced by commonmark-app (jolt-3xur, dl4s, ik3a, w2wf, xtss) (#158)
* Fix four bugs surfaced by the commonmark-app example

- regex: bounded quantifiers {n,m} no longer expand exponentially. The
  desugaring inlined the continuation per optional level, doubling it each
  time, so {0,61} built a PEG the compiler expanded to ~2^61 nodes and hung.
  Each level is now its own grammar rule referenced by name (jolt-3xur).

- strings are a seqable of chars for vec/set/into, matching seq. They went
  through realize-for-iteration, which had no string case, so into/set got
  raw bytes (code points) and set threw; vec used string/from-bytes (1-char
  strings). realize-for-iteration now maps make-char over the bytes, and
  core-vec matches (jolt-dl4s).

- clojure.string/split takes Java Pattern.split limit semantics: negative
  keeps trailing empties, 0/omitted drops them (a no-match result stays
  [input]), positive caps the part count with the remainder unsplit. It used
  to delegate the limit to take, so a negative limit returned [] and the
  2-arg form never trimmed trailing empties (jolt-ik3a).

- System/exit is registered (maps to os/exit), so (System/exit n) works
  (jolt-w2wf).

* regex: single-digit backreferences \1..\9 (jolt-xtss)

\1..\9 now match the text captured by the corresponding group, so
patterns like ([-*_])\1\1 or (\w+) \1 work. The parser records which
groups are referenced; a referenced group additionally captures its text
under a tag and the backref compiles to a PEG (backmatch). Only referenced
groups change — they match possessively (the CPS-over-possessive-PEG engine
can't backtrack into a tagged capture), so backtracking back into a
backreferenced group isn't supported (rare). Unreferenced groups keep full
backtracking and position-based result capture unchanged.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-17 03:56:13 +00:00
Dmitri Sotnikov
8192fc9541
Keep a nil nested in a collection used as a map key (jolt-zcm9) (#157)
canon-key canonicalizes a collection key by re-keying a native Janet
table by the canonical form of each element/entry. canon-key returns nil
for nil, and a Janet struct can't hold a nil key or value, so a nil set
member / nil map key / nil map value was dropped during canonicalization
— #{nil 1} canonicalized like #{1} and collided as a map key. So
(count {#{nil 1} :a, #{1} :b}) was 1 and (get {#{nil 1} :a} #{1}) was :a.

Box a nested nil before it goes into the table. The marker has to be
value-hashable, not the identity-hashed mutable-table sentinel the
transients use: the canonical struct becomes a long-lived phm key, and
its hash must survive the marshal/snapshot/fork that init-cached relies
on — an unmarshalled table gets a fresh address, so its hash isn't
preserved and the map can't find its own key. An interned keyword hashes
by content. Collision risk is only a real value equal to that exact
keyword, the same negligible class as canon-key's existing set/map struct
aliasing.

The transient sentinel stays a mutable table (it's built and consumed
within one op, never crossing a marshal boundary, so identity hashing is
stable there).

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-17 02:11:38 +00:00
Dmitri Sotnikov
630eb2b9d3
Keep a nil element in sets and transient sets (jolt-bn2p) (#156)
Two paths dropped a nil set member. phs-seq read members via
phm-to-struct, whose Janet struct can't hold a nil key, so the nil was
lost on seq/print and on into-an-existing-set even though the backing phm
counted it (count/contains? then disagreed). Re-attach the nil from the
phm's has-nil slot, keeping struct-key order for the rest so set printing
stays stable.

The transient set keyed its native table by canon-key and stored the
member as the value; canon-key returns nil for nil and a nil value also
drops the entry, so conj!/disj!/contains?/persistent! lost a nil member
outright. Key by tbl-key (the same nil sentinel the transient map uses)
and box a nil value through tbl-nil-key, unboxing on persistent!.

A nil-containing set used as a map key still collides with the nil-free
one (canon-key drops nil during key canonicalization) — separate latent
bug, filed as jolt-zcm9.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-17 01:43:23 +00:00
Dmitri Sotnikov
2668a76837
group-by: transient-vector buckets + fix nil keys in transient maps (#155)
The map build already used a transient map, but each bucket was rebuilt with
a persistent (conj (get ret k []) x) per element — an O(log n) trie path
rebuild + alloc each. A coarse grouping (few large buckets) was bound on that
conj, not the map build. Buckets are now native arrays (transient vectors,
O(1) push) frozen once; distinct keys are tracked in a side vector so the
buckets freeze in place with no second map rebuild. A bucket's first element
stays a cheap persistent [x] and only promotes to a transient on the second,
so an all-singletons grouping pays no transient alloc.

  coarse (10/100 buckets, 50k): ~313ms -> ~125ms (~2.5x)
  2 buckets (50k):              ~322ms -> ~129ms (~2.5x)
  all-unique (50k):             ~949ms -> ~892ms (no regression)

Surfaced a latent bug: canon-key returns nil for a nil key and Janet tables
drop a nil key, so the canon-keyed transient map silently lost a nil-key
entry — group-by/frequencies/assoc!/into{} dropped the whole nil bucket
((group-by identity [nil nil 1]) gave {1 [1]}, not {nil [nil nil], 1 [1]}).
Route nil through a sentinel (tbl-key) at the transient-map keying sites;
persistent!/count/dissoc! work unchanged since the real [nil v] pair is kept
as the stored value, and phm already has its own has-nil slot. The transient
set has the analogous bug (needs phs nil support) — filed separately.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 23:10:08 +00:00
Dmitri Sotnikov
1873736045
phm/phs: bulk bottom-up HAMT build for the map/set builders (jolt-5vsp) (#154)
into {}, frequencies, group-by, set, into #{} and persistent! all built
their result by folding an immutable assoc/conj per element — each call
rebuilt the O(log32 n) trie path and allocated a fresh wrapper. Add a
one-pass bottom-up HAMT builder (phm-from-pairs) and route the builders
through it, the map/set analog of the pvec bulk build in #153.

phm-from-pairs partitions entries by hash and constructs the bin/array/
collision nodes directly, with the same bin<=16 / array-node>=17 promotion
the incremental path uses — so the trie is byte-identical to one built by
phm-assoc (validated across the size and branching boundaries, including
hash collisions, duplicate keys and the nil key). persistent! map/set and
the set constructor bulk-build; into {} keeps the small-scalar-map-stays-a
-struct rule via bulk-map-from-pairs; frequencies/group-by switch to the
canonical transient form and ride the fast persistent!.

50k A/B: into {} 704->270ms, frequencies 582->160, set 615->241,
into #{} 702->240, group-by 1358->919 (bound on persistent vector conj).

Gate: conformance x3, full suite (4718 >= baseline), new maps/sets bulk
boundary specs.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 22:24:38 +00:00
Dmitri Sotnikov
43e5426601
pv: build the vector trie bottom-up, not n incremental conjs (jolt-5vsp) (#153)
pv-from-indexed (behind vec/mapv/filterv/into-a-vector) built a pvec by calling
pv-conj once per element — each call allocated a fresh pvec wrapper and copied
the up-to-32 tail tuple, so building an n-vector was O(n) allocations + tail
copies. Replace it with a single bottom-up trie construction: chunk the elements
into 32-wide value leaves, group nodes 32-wide up to the root, split off the
tail. The structure is identical to the incremental one — tail-offset(n) =
((n-1)>>5)<<5 is exactly the trie/tail boundary, so nth/conj/assoc/seq read it
unchanged (validated against the old builder across the size boundaries).

into-a-vector likewise stops doing a persistent pv-conj per element: it
accumulates into a native array and bulk-builds once (the transient-style path).

Measured (50k): vec 211 -> 6 ms (~36x), into [] 197 -> 15 ms (~13x). mapv is
unchanged here — it's bottlenecked on lazy map realization, not the build.

The map/set builders (into {}, frequencies, group-by, set — all HAMT-backed)
need the same bulk treatment and are a separate follow-up. Gate: conformance x3,
full suite, new bulk-boundary rows in vectors-spec.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 21:31:26 +00:00
Dmitri Sotnikov
b771908e5b
jolt.png: host PNG encoder + overlay, render mandelbrot/raytracer demos (#152)
Add a PNG writer so the demos produce actual images. Two pieces:

- src/jolt/png.janet — the encoder (8-bit RGB, filter None, stored/uncompressed
  DEFLATE so no compressor is needed; correct CRC32 + Adler32). It lives in Janet
  because per-byte work in the overlay is far too slow (a byte-array aset loop is
  ~30s for 360k bytes, and CRC32 over even a tiny image would be worse). Janet's
  bit ops are 32-bit signed, so the 32-bit-unsigned arithmetic is done with plain
  number ops (doubles hold 2^32) plus byte-level bxor. Exposed to the overlay as
  janet.png/* by importing it into eval_base's module-load-env.

- src/jolt/jolt/png.clj — the jolt.png overlay wrapper: image / put! / write. The
  overlay only produces pixels; the host encodes them in one pass.

mandelbrot gets a `render` subcommand (jolt -m mandelbrot render out.png [size])
that colours count-point's escape counts; the numeric-arg bench path is untouched.

Verified end to end: macOS `sips` accepts the output (so CRC/zlib are valid).
png-test covers the encoder structure (signature/IHDR/IEND) and the overlay
round-trip.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 21:07:10 +00:00
Dmitri Sotnikov
e2b607f0f3
backend: lower tail loop/recur to while + state vars, not a per-iter closure (jolt-v28u) (#151)
emit-loop compiled every loop/recur to a self-recursive local closure called once
per iteration — relying on Janet TCO for stack safety but paying a fn frame + arg
bind each iteration. The jolt-5vsp spike localized the whole ~1.43x
jolt-over-hand-Janet gap on compute loops to exactly this.

Lower instead to a Janet `while` + state vars: the loop bindings become vars
carried across iterations, a recur writes them and raises a continue flag, and a
non-recur tail value falls out through a result var. recur-name routing in
emit-recur picks the while-set lowering for loops and leaves the fn-arity self-call
path untouched.

The one subtlety is closure capture: Janet closures capture vars BY REFERENCE, so
a closure built in the body over a shared mutable loop var would see the final
value ([3 3 3]) instead of its iteration's ([0 1 2]). Each iteration rebinds the
loop names into a fresh immutable `let` before running the body, which restores
per-iteration capture. recur reads those immutable bindings and writes the state
vars, so cross-referencing args (swap, fib) need no temps.

mandelbrot 218 -> 164 ms (~11.2x JVM, from 15x). fib is unaffected — it's
fn-arity recursion, not a loop. Regression spec in control-flow-spec covers
closure capture, no-clobber recur, nested loops, sequential init, recur through
let, and that fn-arity recur still works. Gate green (conformance x3, full suite).

Note: validating this after a rebuild needs JOLT_NO_DEPS_CACHE=1 — the deps-image
cache keys on the version string, not build identity, so it served stale codegen
(filed separately).

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 21:07:06 +00:00
Dmitri Sotnikov
6cace3db90
cgen: single-binary native build via jolt cgen-build (jolt-a7ds) (#150)
Fuse an app's native-compiled numeric-leaf fns plus its source into one
static executable: no sidecar .so, no toolchain on the target. The AOT path
(#148) already produced a prebuilt module + manifest; this links them into
the jpm-built exe so the app ships as a single file.

`jolt cgen-build -m NS -o OUT` stages a build dir (src/jolt-core symlinks
into the jolt tree, a generated cg.c of the hot fns, an uberscript bundle of
the app, and an entry that bakes the runtime, installs the native fns as var
roots, and runs -main), then runs `jpm build` there — declare-native builds
cg.a and declare-executable static-links it (jpm's create-executable marshals
the module cfns and calls its static entry at startup).

Build needs cc + jpm; the result needs neither. Mechanics that bit, codified
in cgen_build.janet: stdlib_embed slurps .clj cwd-relative so the build runs
in a repo-mirroring dir; jpm hardcodes ./project.janet and sets syspath=modpath;
the executable's dofile imports cg and static-links cg.a, neither ordered nor
release-built by default, so deps are wired explicitly; cleanup must lstat (the
tree symlinks must not be followed); the inner build runs --workers=1 so it
doesn't starve siblings in the parallel gate.

test/integration/cgen-build-test.janet builds the mandelbrot fixture, runs it
from a clean dir with no src/ and no cg.so, and checks the total at native
speed. Closes jolt-a7ds.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 20:34:10 +00:00
Dmitri Sotnikov
bffb492c1c
cgen: build-time AOT — native fns without a toolchain on the target (jolt-a7ds) (#148)
Splits native codegen into a build phase (needs cc) and a deploy phase (none):

- gen-c-module/compile-module compile MANY numeric-leaf fns into ONE native
  module (the AOT shape), generalizing the one-fn-per-.so JIT path.
- Backend :cgen-collect? records each numeric-leaf defn's IR while the app loads
  as bytecode; cgen/aot-build compiles them into one module and write-manifest
  persists {sopath, [{ns name sym}]}.
- Backend :cgen-prebuilt + cgen/load-aot: the deploy run loads the prebuilt .so
  (via the native builtin, no cc) and installs each cfunction as the var root
  with the same timing as the JIT path, so callers direct-link to native code.
- toolchain-available? no longer crashes when cc is off PATH (os/execute raises
  on a missing exe) — a toolchain-less target now gets false.

Proven end-to-end in two processes (spike/native/aot-demo.janet): build with cc,
then deploy with cc removed from PATH -> count-point still native, mandelbrot
3288753 at 12.4ms (full 18x). Test: test/integration/cgen-aot-test.janet. Default
path unchanged; the modes are opt-in. Gate green (118 files).

Remaining for a literal single binary: fuse the .so + manifest into the jpm exe.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 18:09:22 +00:00
Dmitri Sotnikov
ce3c7df24b
cgen: content-address and cache the compiled .so (jolt-ihdp) (#147)
compile-fn now keys the .so on a hash of the generated C + the Janet ABI + the
platform, in a persistent cache dir (default jolt-cgen under TMPDIR, override
with JOLT_CGEN_CACHE_DIR; JOLT_CGEN_NO_CACHE=1 forces a rebuild). cc runs only on
the first build of a given fn; later runs with the same source reuse the cached
.so, so the per-startup compile cost is paid once.

mandelbrot 100 whole-process wall: cold ~0.71s -> warm ~0.21s (the ~0.5s cc
cost). These cache knobs don't shape output, so they stay out of
ctx-shaping-env-vars (same as the image-cache knobs). Test asserts the .so is
content-addressed and a second compile hits the cache without the source .c.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 17:37:35 +00:00
Dmitri Sotnikov
393656d8d9
cgen: install native fns into the compile path under JOLT_CGEN (jolt-ihdp) (#146)
Wires src/jolt/cgen.janet into the backend's :def emit. With JOLT_CGEN=1 (off by
default, needs direct-linking), a plain defn of a numeric-leaf fn is compiled to
C at def time and the cfunction installed as the var root, so direct-linked
callers embed native code. The fn is not inline-stashed when cgen fires —
callers must call the C fn, not inline the bytecode body. ^:redef/^:dynamic stay
bytecode.

The leaf-first rule falls out: run calls count-point (a user var), so run isn't a
numeric leaf and stays bytecode, calling the native count-point over the cheap
forward crossing. mandelbrot 200: 224ms -> 12.4ms (~18x), result unchanged.

Adds JOLT_CGEN to ctx-shaping-env-vars (rides the disk-cache key) and :cgen? to
resolve-run-mode. Default path (cgen off) is a no-op: cgen-root returns nil and
the normal bytecode emit runs. Gate green (117 files). Test:
test/integration/cgen-pipeline-test.janet.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 17:12:48 +00:00
Dmitri Sotnikov
19e8ee906a
cgen: jolt-IR -> C for numeric-leaf fns (jolt-ihdp) (#145)
First slice of the native-codegen tier. A new standalone module, src/jolt/
cgen.janet, that translates a numeric-leaf fn (numeric in/out, body uses only
native-op arithmetic + loop/recur/if/let/do) to a Janet native C module: params
unboxed to C doubles at entry, loop/recur lowered to a while loop, reboxed at
return. compile-fn runs cc and loads the .so via the native builtin, returning a
cfunction; it returns nil for non-candidates or when the toolchain is absent.

count-point compiles and matches the bytecode fn across the mandelbrot grid
(test/integration/cgen-test.janet, which skips the behavioral leg where cc/janet.h
are missing). Nothing wires this into the default compile path yet — detecting
hot fns and installing the C version onto the var cell is the next step.

See docs/foundational-runtime-lever1-native-codegen.md for the ceiling
(native-C ~18-22x faster than bytecode, edges out JVM) and the leaf-first rule.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 16:56:33 +00:00
Dmitri Sotnikov
6772e28eae
Specialize record field reads in method bodies; fix with-meta on symbols (#141)
A protocol method reads its fields through the generic guarded keyword lookup
because the method's `this` param is untyped. defrecord now hints `this` with
the record type, the per-form inference seeds ^Record-hinted params (the
:fn branch previously typed all params :any — only the whole-program path
seeded phints), and run-passes feeds the inference the record shapes. So a
hinted param's field reads bare-index instead of going through the :jolt/type
tag guard.

This needed a with-meta fix: (with-meta sym ..) returned a proto'd table, so
symbol? was false and the macro-attached hint broke fn destructuring. Symbols
now carry metadata in-place in their struct (matching how the reader attaches
^hint), keeping symbol? true, as in Clojure.

Modest on dispatch (~3-5%): the field read is a small fraction of a dispatch;
the machinery (record-tag + protocol lookup + wrapper) dominates, which is the
inline-cache target (jolt-ez5h). But it's a correctness fix and lets any
^Record-hinted code — not just methods — drop the field-read guard per-form,
not only under whole-program.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 15:14:59 +00:00
Dmitri Sotnikov
307b65b45b
Fix -m arg drop under whole-program cache (jolt-4mui) + RFC 0003 sync (#138)
* Bind *command-line-args* after the deps-image cache swap (jolt-4mui)

Under whole-program (deps-image cache active), `jolt -m NS ARG` dropped ARG:
run-main set *command-line-args* on the current ctx, but a cache HIT then
replaced ctx with the saved image (via `set ctx cached`), whose *command-line-
args* was whatever got baked when the image was saved. The stale binding won at
`(apply NS/-main *command-line-args*)`, so -main ran with the wrong (usually
default) args — silently, for any optimized -m program.

Move set-command-line-args to AFTER the cache swap so it binds on the final ctx.
Repro/regression in deps-cache-args-test.janet: first run builds the image
(arg "first"), second run (cache hit) must echo "second", not the baked "first".

* docs: RFC 0003 — phm is a HAMT, sorted colls a red-black tree

The transients RFC described phm as "bucket-based copy-on-write" and mused about
"if it ever becomes a HAMT" — it is one now (jolt-684u), and sorted maps/sets are
a red-black tree (jolt-0hbr). Update the deviation/future-work notes accordingly.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 13:34:08 +00:00
Dmitri Sotnikov
82525b6a81
sorted-map/set: red-black tree instead of O(n) sorted vector (jolt-0hbr) (#137)
Sorted collections were a sorted VECTOR — insert-at = (into (conj (subvec es
0 i) x) (subvec es i)) is O(n) per assoc with a large constant, so building was
O(n^2): 2000 entries took 55.6s.

Replace the rep with a red-black tree (assoc/dissoc/get/contains O(log n)),
ported from the ClojureScript PersistentTreeMap (cljs.core: tree-map-add /
balance-left / balance-right / tree-map-append / balance-*-del). This tier (25)
loads before 30-macros so deftype isn't available; a node is a plain vector
[color k v left right] and cljs's BlackNode/RedNode methods become functions —
the algorithm is unchanged. A sorted-set stores elements as keys with a nil
value; its ops project the key.

The seed read the old :entries vector directly for equality/printing; route
those through a new :entries op that materializes ascending from the tree
(core_types/sorted-entries-arr + main.janet's printer).

2000 sorted-map assocs: 55.6s -> 0.98s (57x); now O(log n) (per-op cost flat
from n=2000 to 10000). Correctness in test/integration/sorted-rbtree-test.janet
(shuffled insert ordering, delete rebalancing, custom comparator, comparator
lookup, subseq, count); sorted specs + full gate green. (key/val on sorted
entries stays a pre-existing gap — entries are pvecs not host tuples; jolt-jk23.)

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 06:04:46 +00:00
Dmitri Sotnikov
91e246c682
Persistent hash map: HAMT instead of O(n) copy-on-write (jolt-684u) (#136)
* Add benchmark suite for alloc/dispatch/collection workloads (jolt-1r86)

The ray tracer is float-compute-bound (devirt, alloc removal, type-proving all
measured flat on it), so it can't validate the optimization passes. Add a small
cross-language suite (AWFY + CLBG style, portable Clojure) isolating the axes it
misses:

  binary-trees  allocation / GC pressure (escaping short-lived records)
  dispatch      megamorphic protocol dispatch (~1M dispatches/s; WP can't devirt)
  collections   persistent map/vector churn

bench/run.sh runs them; bench/README.md maps each to the pass it exercises.

collections immediately surfaced jolt-684u: the persistent hash map is O(n) per
assoc (flat copy-on-write bucket array, not a HAMT) — n=4000 assocs take 50s.
Invisible to the ray tracer (no maps).

* Persistent hash map: HAMT instead of O(n) copy-on-write (jolt-684u)

The map was a flat bucket array whose assoc copied the whole array every insert
(O(n)/assoc, O(n^2) to build). Compounding it, small maps are Janet structs that
only promoted to phm for collection keys — never for size — so a scalar-key map
stayed an O(n)-copy struct forever. Building a 4000-entry map took ~50s.

Two fixes, following ClojureScript's design:

- phm.janet is now a HAMT (hash array mapped trie): BitmapIndexedNode /
  ArrayNode / HashCollisionNode, 32-way, 5 hash bits per level, structural
  sharing — assoc/dissoc/get are O(log32 n). Translated from cljs.core, adapted
  to Janet's 32-bit bit-ops (the hash is carried unsigned, the level index is
  extracted with arithmetic, and bits are tested with band against 1<<i since
  brushift rejects negative bitmaps). The public phm-* API and the value shape
  (:jolt/type :jolt/phm, :cnt) are unchanged; transients are a separate rep and
  untouched.

- core_coll promotes a struct map to a phm past 8 entries (not only for
  collection keys), mirroring cljs PersistentArrayMap -> PersistentHashMap, so
  incremental building isn't O(n^2).

20000 raw assocs: 7.1s -> 0.105s. The collections benchmark: 16.7s -> 0.2s.
Correctness covered by test/unit/phm-hamt-test.janet (oracle vs a Janet table,
nil keys, dissoc, a real hash-collision pair, and a sub-linear-assoc guard);
full gate green.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 05:01:22 +00:00
Dmitri Sotnikov
bd9e542c78
Propagate declared record hints through the whole-program fixpoint (jolt-3ko) (#134)
A ^Record param hint was applied only at the final re-emit (reinfer-def), not
during the inter-procedural fixpoint. So a hinted param with no callers stayed
:any while inference ran, and a field read off it (e.g. (:origin ^Ray r)) never
told a non-inlined callee that its arg is a Vec3 — the callee's params stayed
unproven and its field reads kept the dynamic guard.

Seed declared hints as a param-type floor in the fixpoint: phint-seed (passes/
types) resolves an arity's :phints to positional record types via the
record-shapes registry, and infer-unit! initializes each fn's fresh param slots
from them instead of nil. A fixed declared type can't poison the least-fixpoint
the way an early-iteration :any would, and a hinted param now propagates its
(and its field reads') types to its callees during inference.

Scope: this closes the hinted-propagation gap. It does NOT help the ray tracer,
which uses zero ^-hinted params (only hinted fields) — its remaining type gap is
unhinted record-param inference on recursive/non-inlined hot fns, and per the
jolt-15jq A/B it's allocation-bound regardless (jolt-8flj). Tracked on the bead.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 03:38:03 +00:00
Dmitri Sotnikov
30a12f39ff
Fold jolt-deps into the jolt binary (#133)
Dependency resolution now lives in the `jolt` CLI itself instead of a separate
jolt-deps executable. `jolt` resolves a deps.edn into JOLT_PATH/JOLT_APP_PATHS
in-process and dispatches the deps subcommands:

  jolt -M:alias [args]   run the alias :main-opts
  jolt -A:alias CMD      run CMD with the alias paths
  jolt run FILE          resolve, then run FILE
  jolt path | tasks | task NAME

A deps.edn in the working dir is auto-resolved for the runnable commands
(repl/-m/-e/nrepl-server/FILE), so e.g. `jolt -M:nrepl` (or plain
`jolt nrepl-server`) starts an nREPL with the project and its deps loaded.

The runtime core stays deps-agnostic — it only reads JOLT_PATH. The resolver
(deps.janet) is reached only from the CLI entry and loads jpm lazily, so a run
with no deps.edn never touches it and an app baked from its own jolt/api entry
never links it. resolve-deps-argv only resolves on an explicit deps command or
when a deps.edn is present; help/version never do.

jolt-deps stays as a thin deprecation shim that forwards to `jolt`, so existing
scripts keep working. Docs (README, CLAUDE.md, building-and-deps, tools-deps)
and the help text updated.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 10:30:28 +08:00
Dmitri Sotnikov
048de0200d
Scalar-replace short-lived record allocations (jolt-15jq) (#132)
scalar-replace already folds non-escaping const-key map literals
((:k {:k a ..}) -> a, and drops a let-bound map that doesn't escape).
Extend the same fold to record constructors: a (->Rec a b c) is a
positional struct whose declared field order lives in the record-shapes
registry, so a field read on a non-escaping ctor folds to the matching
positional arg and the allocation disappears.

Direct form (:field (->Rec ..)) and the let-bound form both handled,
threaded through run-passes via a per-unit shape registry (new
jolt.host/record-shapes accessor). Soundness: ctor args must be pure
(duplicated/discarded like map vals), arg count must equal the field
count, and only declared-field reads fold — a record answers the virtual
:jolt/deftype key with its type tag and any other key with nil, neither
of which is a positional arg, so those keep the allocation. pure? now
treats a record ctor of pure args as pure, so nested records (a Ray
holding a Vec3) fold bottom-up.

Allocation-bound microbench (non-escaping record built + field-read in a
hot loop): 69.6s -> 2.4s, landing on the no-record arithmetic baseline.
The ray tracer is unchanged — its vec3 results escape (returned/stored
each op), so they genuinely allocate; that's a separate problem.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-16 01:21:36 +00:00
Dmitri Sotnikov
04d3e192ed
Dead-code elimination in jolt uberscript (jolt-atg) (#130)
A bundle is closed-world — everything it needs is inlined and nothing is
required afterward — so a user defn unreachable from the entry's reference
graph can be dropped. The bundler now computes reachability from main-ns/-main
plus every non-prunable form and drops dead defn/defn- by exact source span
(formatting and reader macros in the surviving code are untouched).

Conservative and sound: only plain defn/defn- are prunable; a defn is kept if
its bare or ns-qualified name appears in any kept form, the closure runs to a
fixpoint, and any use of resolve/ns-resolve/requiring-resolve/find-var/intern/
eval/load-string disables pruning entirely. A parse failure on any file also
falls back to verbatim bundling, so the command stays as robust as a plain
concatenation. defmethod/defrecord/extend bodies are non-prunable and scanned,
so a fn reached only via dynamic dispatch stays live.

New reader/parse-all-spans returns [form start end] byte offsets so the drop
is a verbatim slice, not a re-print.

30-fn library used by a 3-fn entry: bundle 1114 -> 437 bytes (61% smaller, 27
dead fns dropped), output byte-identical.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 22:25:44 +00:00
Dmitri Sotnikov
61d621521b
Library ports: get hiccup running, verify malli (reader + interop fixes) (#127)
* Reader: #() params survive syntax-quote (auto-gensym names)

#(...) named its synthesized params with bare gensyms, so a #() written inside a
syntax-quote had its params qualified to the current ns by sq-symbol — and a
qualified symbol isn't a valid fn param. hiccup's compiler emits
`(let [sb# ..] (iterate! #(.append sb# %) ..)), which broke with "Unable to
resolve symbol: ns/_NNNN".

Name the params with a trailing # (auto-gensym suffix, like Clojure's p1__N#) so
syntax-quote maps them consistently and leaves them unqualified. Harmless outside
a backtick (just a regular symbol name).

* interop: String/valueOf static + String is a CharSequence

Two interop gaps surfaced bringing up hiccup and malli:
- String/valueOf(Object): hiccup's compiler stringifies attribute values with
  (String/valueOf (or arg "")). Added the static — "null" for nil, else core-str.
- (instance? CharSequence s) returned false for a string; String implements
  CharSequence, and malli's :re validator gates on it before matching, so :re
  schemas always failed. instance-check now answers true for strings.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 19:36:13 +00:00
Dmitri Sotnikov
d223f40c91
Scope whole-program optimization to app namespaces (jolt-87e) (#126)
Under JOLT_OPTIMIZE a -m program run inferred + specialized EVERY loaded
namespace, including every transitive dependency. On a dep-heavy app that's
prohibitive: malli-app cold-started in ~2m10s (hundreds of dep namespaces, each
run through the per-form inline + inference passes).

The closed world a whole-program pass reasons over is the APP, not its
libraries. jolt-deps now passes the project's own source roots (its deps.edn
:paths) to the runtime as JOLT_APP_PATHS. A namespace loaded from an app root
gets full optimization (and joins the one whole-program fixpoint); a dependency
namespace compiles at default cost — :inline? off for its load, so the per-form
optimize passes don't run over library code — staying direct-linked but
generically typed (the open-world default). With no app roots declared (a bare
program run, or jolt without jolt-deps) everything counts as app, so behavior is
unchanged.

malli-app JOLT_OPTIMIZE cold start: 2m10s -> 4.5s. Compute-heavy programs whose
hot code is their own namespaces (the typed ray tracer) are unaffected — their
code is app code and still fully optimized (9s/frame render). Applied at runtime
in main for the same baked-at-build-time reason as JOLT_PATH; added to the
ctx-image cache key. Help text corrected: optimization is opt-in, not default.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 18:02:54 +00:00
Dmitri Sotnikov
d200725811
Support mutable deftype fields under shapes (jolt-c3q) (#125)
A deftype field tagged ^:unsynchronized-mutable / ^:volatile-mutable is set!-able,
but under direct-link immutable records are shape-rec tuples, so set! errored
("Can't set! field on non-deftype: tuple").

A deftype with any mutable field now opts out of the shape-rec layout and uses
the existing :jolt/deftype table form regardless of :shapes? — set! already
mutates that form and field reads route through the tagged-table path. Such a
type is also not registered as a shape, so the inference never emits a bare-index
read against the table. Immutable deftypes/records keep the fast shape-rec.

deftype extracts per-field mutability from the field metadata and passes it to
make-deftype-ctor, which picks the representation at ctor-build time.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 17:45:29 +00:00
Dmitri Sotnikov
8ee04eed8e
Fix record field-reorder redefine (jolt-wf4) and builtin-shadowing local names (jolt-fjb1) (#124)
* Don't direct-link a var redefined earlier in the same unit (jolt-wf4)

defrecord/deftype expands to (do (def R (make-deftype-ctor ...)) (def ->R R) ...),
so the ->R alias references R within one compiled unit. Under direct-link a var
ref embeds the cell's root as a compile-time constant, but on a redefine R's old
root is still in place when that unit compiles — the (def R new) sibling hasn't
run yet — so ->R sealed to the stale pre-redef ctor. (defrecord R [x a])
(defrecord R [a x]) (:a (->R 10 20)) read the old [x a] layout and returned 20.

Track the vars a unit (re)defines and force their later in-unit references to the
live indirect deref. The cell is registered only after its own init is emitted,
so a recursive self-reference inside the init still direct-links (it runs after
the def completes); only sibling references after the def go indirect.

* Emit Janet's `in` as a value so a user local can't shadow it (jolt-fjb1)

The back end emits `in` to deref var cells ((in cell :root)) and index
shape-recs. It emitted the bare symbol, so a user local named `in` shadowed
Janet's builtin in the surrounding scope and the generated cell-deref called the
user's value as a function — "<table> called with 2 arguments, possibly expected
1". malli's explainer binds [value in acc], so m/explain hit this on every
schema (m/validate was unaffected — its path doesn't bind `in`).

Embed `in`'s function value at the emit sites (as jolt-call/core-get already
are); a value in head position can't be shadowed. Fixes m/explain on malli
(loaded with JOLT_FEATURES=clj so its .cljc reader-conditionals resolve).

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 16:46:00 +00:00
Yogthos
80aae55977 Evaluate non-array ISeq forms (cons/concat/lazy-seq) as calls (jolt-2rx)
eval-form treated only a reader LIST (a Janet array) as a call; a runtime-built
list — a plist or lazy-seq from cons/concat/list or ~@ (list?/seq? true but
array? false) — fell through to self-eval. So (eval (cons '+ '(1 2))) returned
the list as data instead of 3, and a macro whose output contained such a subform
left it unevaluated. Add a plist?/lazy-seq? branch that coerces to the element
array via d-realize and dispatches through eval-list; an empty list self-evals.

The analyzer already punts these forms to the interpreter (analyze's :else ->
uncompilable -> interpreter fallback), so this one interpreter branch fixes the
correctness bug across the eval and macro-expansion paths; compiling them
directly (vs punting) would be a separate perf change. Verified: conformance
355/355, syntax-quote ~@ splice, list values unchanged.
2026-06-15 10:27:34 -04:00
Yogthos
a2c4fe317b Honor a deftype's custom Object/toString in .toString and str (jolt-rt6n)
A deftype with (Object (toString [_] s)) had its toString ignored: the generic
object-methods "toString" fired in dispatch-member before the record's own
method (the record isn't a tagged shim, so that guard passed), and str rendered
the #Type{...} data repr instead of routing through toString.

- dispatch-member: a record's own method (instance/reified/protocol) now wins
  over the generic object-methods table — so .toString/.equals/.hashCode on a
  record use the record's definitions; plain records still reach object-methods.
- str: add a late-bound record-tostring-cb (wired per-ctx by
  install-print-method-cb!, mirroring print-method-cb) that str-render-one
  consults for records — a deftype with a custom toString renders via it, plain
  records keep the data repr. pr-str is unchanged.

Needed by hiccup's RawString. Adds deftype-tostring-spec (.toString + str +
concatenation + a regression guard that record-less-toString keeps its repr).
2026-06-15 10:22:09 -04:00
Yogthos
fb1ec25c69 Fix clojure.walk to descend lists and seqs (jolt-khk)
walk only handled vector?/map? and fell through :else for everything else, so
postwalk over a quoted list (a plist) never touched its elements —
postwalk-replace with symbol keys silently no-op'd, which broke
clojure.template/apply-template (found during reitit work). Add list? (rebuild as
a list) and seq? (map over it) branches after the vector/map ones so concrete
collections stay authoritative. Adds walk-spec covering list/seq walking plus a
vector/keywordize regression guard and the apply-template trigger.
2026-06-15 10:16:47 -04:00
Yogthos
7cb86e8f7b Refactor phase 1: split host-interop into a src/jolt/interop/ subdir (jolt-jx5l)
The 813-line host_interop.janet (java.lang statics, java.time, the 458-line
java.io/util/net/sql/text install-io!, and collection interop all in one file)
becomes a 19-line aggregator over src/jolt/interop/:
- java_base.janet  — java.lang statics (Math/Thread/System/Long) + the java.time
                     shim + the shared chr/pad2/formatter coercion helpers
- host_io.janet    — java.io/util/net/sql/text shims; imports java_base for the
                     shared helpers it reuses (it constructs java.time values and
                     formats dates)
- collections.janet— the late-bound .iterator/.nth/.count/.seq interop hooks

host_interop.janet just loads the three and runs install!/install-io!/
install-collections! in order. chr/pad2/formatter become public (they cross the
java_base->host_io boundary now). The registry machinery (class-statics/
tagged-methods/register-*!) stays in the evaluator, which loads first — moving it
out touches the hot dot-dispatch and is left as a separate step.

Pure move, no behavior change. Also fixes a cache footgun the subdir exposed:
source-fingerprint walked src/jolt/ with a non-recursive (os/dir), so an edit to
an interop/ file would not have busted the ctx image cache (stale-image bug) —
made it recurse, keyed by repo-relative path.

Full gate green: host-interop-spec (130+ rows across every JDK area), ctx-image
cold/warm, conformance x3, suite >=4695/88, fixpoint.
2026-06-15 09:37:28 -04:00
Dmitri Sotnikov
10482dae35
Merge pull request #121 from jolt-lang/refactor-phase4b-ctx-image
Refactor phase 4b: share the ctx-image load/save dance (jolt-q5ql)
2026-06-15 13:26:38 +00:00
Yogthos
21721473a5 Refactor phase 4b: share the ctx-image load/save dance (jolt-q5ql)
init-cached (core image) and the deps-image (main.janet) each hand-wrote the same
fork->slurp->validate-ctx?->rewire load and the snapshot->tmp->atomic-rename save.
Extract load-ctx-image / save-ctx-image (in api.janet, beside snapshot/fork): the
two callers now differ only in the validity predicate they pass — none for the
core image (its source fingerprint is already in the path), a deps-manifest mtime
check for the deps image. The per-process print-method-cb rewiring an image
restore must replay is threaded as a callback so the helpers don't depend on core.

Kept in api.janet rather than a new ctx_image.janet module: Janet's `use` doesn't
transitively re-export, and snapshot/fork already live in api and are consumed via
(use ./api) by main and the test harnesses — a separate module would force every
caller to import it directly. (load-image/save-image collide with Janet builtins,
hence the ctx- prefix.)

Full gate green; ctx-image-test cold/warm + deps tests pass.
2026-06-15 09:08:56 -04:00
Yogthos
dd4100db73 Refactor phase 5c: dispatch core collection ops on :jolt/type via one case (jolt-bvek)
core-count/core-seq/core-conj each walked a chain of (and (table? x) (= :jolt/X
(get x :jolt/type))) predicates — re-fetching the type tag per predicate and, for
conj, a 14-deep nested if. Replace with a single (case (get coll :jolt/type) ...)
per op: the type is read once and the arm calls the concrete op directly. Host
values (tuple/array/nil) and tuple-based shape-recs carry no :jolt/type and stay
in a per-op fallback cond (shape-rec? kept before tuple? — shape-recs ARE tuples).
Factor the two map-conj paths (phm vs struct) into one conj-into-map parameterized
by the assoc fn.

Behavior-preserving: the :jolt/type tags are disjoint, so the case is an exact
re-expression of the predicate chain; the fallbacks reproduce the originals
(incl. count erroring on a raw host table, which only seq ever handled).

This is the perf-neutral realization of the planned collection vtable. A shared
:jolt/type->{ops} table was tried first and REGRESSED core-bench ~2-4% (table
lookup + indirect call on the hot path); the inline case instead runs ~1.7%
FASTER than main (4864ms vs ~4950ms baseline) since one type-get + a jump beats
the sequential predicate chain. Full gate green (suite >=4695/88, fixpoint).
2026-06-15 08:51:34 -04:00
Dmitri Sotnikov
61e4bbf2de
Merge pull request #117 from jolt-lang/dedup-eval-dot-member
Dedup the two .method dot-dispatch arms into one dispatch-member (jolt-eos3)
2026-06-15 08:58:01 +00:00
Yogthos
0406b315a9 Dedup the two .method dot-dispatch arms into one dispatch-member (jolt-eos3)
eval-dot copy-pasted its entire dispatch chain across the (. obj method args...)
and (. obj member) forms — string/number/object/tagged-shim lookup duplicated,
hand-synced on every interop change. Extract one dispatch-member that takes the
evaluated args plus a has-args flag. The shared head (string/number/object/
tagged) is single-sourced; the genuinely divergent tails (call form: record →
native field → coll-interop(args); bare form: zero-arg coll-interop → field /
zero-arg method) stay branched on has-args. The guards that differed between the
arms (object-methods checks table? only; tagged dispatch checks table-or-struct;
bare-form tagged dispatch requires the member present) are preserved verbatim,
keyed off has-args, so behavior is identical.

Adds a "dot dispatch arms" spec locking the divergent cases: zero-arg vs
with-arg coll-interop, record/deftype zero-arg vs with-args methods, -field
access. Full gate green.
2026-06-15 04:35:52 -04:00
Yogthos
793b55f1f3 Refactor phase 5d/5e: seed↔overlay registry + rep↔API boundary docs (jolt-bvek)
5d: document the seed↔overlay boundary and add a drift check. core fns split
across a Janet seed (core-X, registered in core-bindings) and a Clojure overlay;
five names (char?/sorted?/sorted-map?/sorted-set?/transduce) carry a defn in
both, with the overlay copy authoritative and the seed copy internal-only. The
into-vs-transduce home asymmetry was undocumented. Adds docs/seed-overlay-registry.md,
SEED-TWIN: comments at the five seed sites, and a build-time drift check
(test/unit/seed-overlay-registry-test.janet) that recomputes the twin set from
source and fails if it diverges or a twin leaks into core-bindings.

5e: rep↔API pointer comments in pv/plist/phm/phs/lazyseq (representation lives
here; Clojure-facing ops dispatch in core_coll/core_types) and back-pointers in
core_coll. No behavior change — comments, docs, one source-analysis test.

Full gate green (suite ≥4695 pass / ≥88 clean files), drift check passes.
2026-06-15 04:22:05 -04:00
Dmitri Sotnikov
6c0142e625
Merge pull request #115 from jolt-lang/refactor-phase4-config-cache
Refactor phase 4: lift run-mode into config + fix cache-key footgun (jolt-q5ql)
2026-06-15 07:32:11 +00:00
Dmitri Sotnikov
7632be811b
Merge pull request #114 from jolt-lang/refactor-phase3c-dedups
Refactor phase 3c: dedup collection readers + phm bucket scans (jolt-26dm)
2026-06-15 07:15:44 +00:00
Yogthos
fbca0890f5 Refactor phase 4: lift run-mode into config.janet + fix the cache-key footgun
main.janet held ~45 lines of env-knob policy (open-mode / direct-link / optimize
/ shapes / whole-program gates) that couldn't be unit-tested without the CLI, and
two disk-image caches (api/init-cached, main/deps-image) each hand-built a
POSITIONAL "%q|%q|..." key that silently misaligned if a ctx-shaping knob was
added in only one place.

config.janet now owns:
  ctx-shaping-env-vars  the canonical list of env vars that shape the built ctx
  ctx-cache-key         a labeled key (name=value) over a prefix + every shaping
                        var, so adding a knob updates BOTH cache keys at once and
                        can't positionally alias two different builds
  resolve-run-mode      [open-mode? main-entry?] -> the ctx env knob map

main shrinks to: compute open-mode?/main-entry? from argv, call resolve-run-mode,
install the knobs. Both deps-image-path (main) and image-cache-path (api) build
their keys via ctx-cache-key. New test/unit/config-test.janet locks in the
run-mode cases and asserts every ctx-shaping env var participates in the key.

Scope: this is 4a + the 4b cache-key footgun fix. The optional 4b cleanup
(folding the load/save image dance + aot marshal helpers into one ctx_image
module) is left for a follow-up — it's lower value and higher blast radius.

No behavior change (cache keys now key on a superset of env vars, so at worst a
one-time cold rebuild). Gate green: conformance 355x3, clojure-test-suite 4718
pass (>= 4695 baseline), config-test, full jpm test exit 0.
2026-06-15 03:15:24 -04:00
Dmitri Sotnikov
5ad8e8281a
Merge pull request #113 from jolt-lang/refactor-types-split
Refactor phase 5a: split types.janet into value-layer modules (jolt-bvek)
2026-06-15 06:59:50 +00:00