Commit graph

442 commits

Author SHA1 Message Date
Dmitri Sotnikov
ea5c3452de
Merge pull request #111 from jolt-lang/refactor-core-split
Refactor phase 2b: split core.janet into cluster modules (jolt-nma8)
2026-06-15 06:21:16 +00:00
Yogthos
0c324178d4 Refactor phase 2b: split core.janet into cluster modules behind a re-export aggregator
core.janet was a 3013-line grab-bag. Split into six order-preserving cluster
modules:

  core_types  vector helpers, predicates, math, comparison, equality
  core_coll   collections, transducers, seqs, HOFs, constructors
  core_print  string + pr-str/str rendering
  core_io     I/O, files, JDBC, compare, type
  core_refs   arrays, bit ops, coercions, hash, atoms/refs
  core_extra  additional clojure.core fns, transients, hashing

core.janet stays the module everyone imports: it loads the clusters in
dependency order and re-exports each one's defs (import :prefix "" :export true),
so every consumer keeps its single (use ./core) with no change. The aggregator
also owns core-bindings and init-core!, which reference fns from every cluster.

The split preserves definition order exactly (verified: no symbol is used in a
cluster that loads before its definition), so seed load-order semantics are
unchanged. Three private helpers used across clusters (map-entries-of,
map-assoc1, str-render-one) are now public so `use` shares them; the five
forward vars (canon-key/jolt-equal?/pr-render/core-compare/print-method-cb) each
stay within their owning cluster.

No behavior change. Gate green: conformance 355x3, clojure-test-suite 4718 pass
(>= 4695 baseline), full jpm test exit 0.
2026-06-15 02:04:17 -04:00
Dmitri Sotnikov
c9f60d70c4
Merge pull request #110 from jolt-lang/refactor-passes-split
Refactor phase 2c: split passes.clj into fold/inline/types (jolt-8qrw)
2026-06-15 05:58:04 +00:00
Yogthos
8e634e7106 Refactor phase 2c: split passes.clj into fold/inline/types behind a façade
passes.clj was a 1486-line grab-bag mixing three weakly-coupled concerns. Split
along the clusters the review mapped (only run-passes + the dirty flag were
shared):

  jolt.passes.fold    const-fold + the shared scalar-const? predicate (base)
  jolt.passes.inline  inline + flatten-lets + scalar-replace
  jolt.passes.types   collection-type inference + success checker + driver API
  jolt.passes         façade: run-passes + :refer re-exports of the driver fns
                      the back end looks up by name

scalar-const? was used by both the inline pass and the inference walk, so it
moves to fold (the base layer) and both refer it. The check-mode state stays
private to jolt.passes.types behind a new run-inference fn; run-passes calls it.

build-compiler! loads the three in dependency order before the façade, mirroring
the existing jolt.ir -> jolt.analyzer bootstrap. No behavior change. Also fixed
the stale ns docstring that listed four passes and omitted the type system.

Gate green: conformance 355x3, clojure-test-suite 4718 pass (>= 4695 baseline),
full jpm test exit 0.
2026-06-15 01:41:28 -04:00
Dmitri Sotnikov
b06855db1f
Merge pull request #109 from jolt-lang/refactor-phs-split
Refactor: extract PersistentHashSet from phm.janet (jolt-bvek)
2026-06-15 05:19:05 +00:00
Yogthos
bad418e917 Refactor: extract PersistentHashSet from phm.janet into phs.janet
Completes the phm.janet decomposition (jolt-bvek): after lazyseq left, the set
follows. phm.janet is now purely the PersistentHashMap; phs.janet is the thin
set layer over it (members are keys -> true), and (use ./phm) for the builders.

Importers using set?/phs-* via (use ./phm) add (use ./phs); backend's emitted
set literal head changes phm/make-phs -> phs/make-phs. Behaviour unchanged
(sets verified interpreted + compiled; full gate green).
2026-06-15 00:54:05 -04:00
Dmitri Sotnikov
7cffe85298
Refactor: extract LazySeq from phm.janet into lazyseq.janet (#108)
phm.janet held the PersistentHashMap, the PersistentHashSet, AND the LazySeq
primitives — a lazy sequence has nothing to do with hash maps; both were just
tagged tables, which is why they shared a file (jolt-bvek). An agent looking for
lazy-seq realization would never grep phm.janet.

Move the LazySeq section (lazy-seq?/make-lazy-seq/realize-ls/ls-first/ls-rest/
ls-rest-cached/ls-seq/ls-count/lazy-cons) to a new self-contained lazyseq.janet
(janet builtins only, no jolt deps). Importers that used the fns through
(use ./phm) add (use ./lazyseq); host_interop's one phm/lazy-seq? becomes
lazy-seq?. Behaviour unchanged (covered by test/unit/lazy-seq-test.janet + the
full gate). phs split is a follow-up.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 04:41:39 +00:00
Dmitri Sotnikov
62fe11fa0d
Refactor phase 1: consolidate host interop into one module (#107)
Cheap-version consolidation of the host-interop sprawl (jolt-jx5l). The JVM
class/method shims were scattered across javatime.janet, evaluator.janet,
api.janet and core.janet; the recent hiccup/markdown/malli fixes landed ad hoc.

- Rename javatime.janet -> host_interop.janet. The name described ~20% of its
  contents (java.io/util/net/sql/lang all lived there); it is now the one home
  for host shims, and greppable.
- Move the four hardcoded static tables (Math/Thread/System/Long) out of the
  evaluator and register them through the generic class-statics registry. The
  special-case dispatch in resolve-sym is deleted — one mechanism, not two.
- Move the collection-interop wiring (set-coll-realizer!, set-coll-interop!,
  malli's LazilyPersistentVector/PersistentArrayMap statics) from api.janet into
  host_interop's install-collections!. api's 40 lines of wiring become a single
  (import ./host_interop).

No behavior change. Full per-package src/jolt/interop/ split is the follow-up;
string/number/object method tables and core's File/JDBC ctors stay put for now
(they're coupled to the dot-dispatch / collection layer). Full gate green.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 03:56:25 +00:00
Dmitri Sotnikov
910c4b6c99
Protocol/interop fixes to run metosin/malli (jolt-ltwk) (#105)
* Protocol/interop fixes to run metosin/malli

Bringing up malli (schema validation) surfaced a batch of protocol and host-interop
gaps. m/validate now works across the schema vocabulary (predicates, :map incl.
nested/optional, :vector, :tuple, :enum, :maybe, :and, bounded int/string).

- extend-type and reify now accept MULTIPLE protocols in one form (each bare
  symbol switches the current protocol). reify records every protocol it
  implements, so instance?/satisfies? recognise all of them.
- Protocol method params support destructuring: reify/extend-type/deftype/
  defrecord emit (fn ...) (which desugars patterns) instead of raw fn*.
- instance? of a PROTOCOL works like satisfies? for reify/record instances,
  matching short names across qualified/bare protocol references.
- @x reads as the qualified clojure.core/deref, so it still derefs where a ns
  excludes and rebinds deref (malli does). Updated reader-test + the reader
  spec/grammar (S11, deref rule).
- Java collection interop on jolt collections: .nth/.count/.valAt/.get/.seq/
  .containsKey route to the clojure.core equivalent (1-arg and 0-arg paths).
- java.util.HashMap capacity/load-factor constructors + .putAll.
- A class used as a value resolves to its instances' type, so Pattern -> the
  regex type (malli keys class-schemas by it).
- Shims for malli's load path: LazilyPersistentVector/createOwning and
  PersistentArrayMap/createWithCheck statics.

m/explain not yet working (jolt-fjb1). Full gate green.

* satisfies? recognizes reify, consistent with instance?

A reify's protocol methods are instance-local, so they aren't in the global type
registry that type-satisfies? consults — satisfies? returned false for a reify
even when it implemented the protocol. Check the protocols the reify records on
itself (the same :jolt/protocols list instance? uses), matching short names like
instance? does. Covers single- and multi-protocol reify.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 03:20:33 +00:00
Dmitri Sotnikov
d1f73f1740
Architecture refactor: plan + phase 0 (dead code + bugs) (#106)
* Add architecture refactor plan

Synthesizes a six-part architectural review into phased, gate-validated cleanup
work. Targets LLM-maintainability: one home per feature, no god-files, explicit
checked contracts, no copy-paste dispatch. No code changes yet — the plan only.

* Refactor phase 0: dead code + isolated bugs

Pure cleanup ahead of the structural phases (docs/architecture-refactor-plan.md).
No behavior change except the two bug fixes, which are covered by a regression row.

Dead code (all verified zero-reference or overridden):
- core-resolve / core-satisfies? / core-type->str seed stubs + bindings —
  resolve and satisfies? are interned by install-stateful-fns! (the seed copies
  were shadowed); type->str was an inert SCI stub with no callers.
- find defined twice in 20-coll.clj; the dead copy returned a plain vector
  (wrong — the live def at :787 returns a real map-entry) with a comment that
  contradicted it.
- mark-hint (passes.clj), phs-to-struct (phm), shape-vals / ns-imports-fn
  (types) — unreferenced.
- redundant local pad2 in javatime (module-level one already in scope).

Bugs:
- File.toURL stored :url but every :jolt/url method reads :spec, so a URL from
  (.toURL file) returned nil from all its methods. Now stores :spec (+ spec row).
- pl-rest had a no-op (if (plist? r) r r); collapsed to r.
- :map-shapes? was missing from the deps-image cache key — two runs differing
  only in map-shapes could reuse each other's image.

Also dropped read-quote's unused pos param. Full gate green.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 03:01:55 +00:00
Dmitri Sotnikov
88b7d13401
Stdlib/reader fixes to run markdown-clj (#104)
Bringing up yogthos/markdown-clj surfaced a batch of Clojure-conformance gaps:

- clojure.java.io/writer returned nil for a Writer/StringWriter (it only
  handled paths); now passes a Writer or file handle through, like reader does.
- StringWriter had no :close field, so with-open errored closing it.
- java.io.Reader had no .readLine method (only the :read-line-fn used by
  line-seq); markdown's main loop calls .readLine directly.
- Writer.write(int) wrote the int's digits instead of the char for that code.
  StringBuilder.append(int) keeps Java semantics (the digits) — the two differ,
  so the char-code path is local to the writer, not shared render-piece.
- drop-while over a string errored in array/slice; it now char-seqs the string
  like take-while/remove already do.
- re-seq returned an empty seq instead of nil on no match, so
  (if-let [m (re-seq ...)] ...) always took the truthy branch — an infinite loop
  in markdown's thaw-string.
- The #() reader didn't scan % inside map {} or set #{} literals, so
  #(identity {:text %}) compiled as a 0-arg fn.

re-seq-nil and the #() map/set scan are general bugs, not markdown-specific.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 01:48:15 +00:00
Dmitri Sotnikov
288b20956c
Shim java.util.Iterator: (.iterator coll)/.hasNext/.next over jolt collections (#103)
Some Clojure libraries loop with the Java Iterator protocol — e.g. hiccup's
iterate! does (let [it (.iterator coll)] (while (.hasNext it) (f (.next it)))).
jolt had no Iterator, so (.iterator coll) returned nil and the loop did nothing
(silently dropping content). Add an (.iterator coll) object-method that returns a
:jolt/iterator over any seqable, with hasNext/next; the collection is materialized
via core's realize-for-iteration (late-bound through the evaluator since core
loads after it, wired in api). Found while bringing up weavejester/hiccup.

Co-authored-by: Yogthos <yogthos@gmail.com>
2026-06-15 00:28:45 +00:00
Dmitri Sotnikov
874b6140ee
Merge pull request #102 from jolt-lang/startup-perf
Fast startup: inference opt-in, dependencies compile once (jolt-87e)
2026-06-14 22:47:52 +00:00
Yogthos
9f1cebc097 Fast startup: inference is opt-in, and dependencies compile once (jolt-87e)
Two things made a program run catastrophically slow to start (ring-app: 111s),
which is backwards — direct-linking should make startup FASTER (Clojure: smaller
classes, faster startup; Stalin: whole-program analysis is strictly ahead-of-
time, zero startup cost). We were doing the opposite: paying compile-time work at
every startup.

1. Decouple inference from direct-linking. Direct-linking (cheap compile-time
   call resolution) was bundled with the whole inference/specialization pipeline
   (inlining + scalar replacement + structural types + the per-ns re-emit
   fixpoint) — the expensive part. Now a program run direct-links + uses records
   by default (fast, faster calls) but the inference is opt-in via JOLT_OPTIMIZE
   (or an explicit JOLT_DIRECT_LINK / the native build). ring-app: 111s -> 6s.

2. Compile dependencies once. The baked core loads in ~10ms, but a program still
   re-compiled every dependency namespace (reitit, ring, selmer, honeysql, …)
   from source on every run — the whole 6s. Snapshot the ctx after the require
   chain to a per-project image (the same marshal/fork the core image uses),
   keyed on version + entry + source roots + flags, with a per-file mtime
   manifest so any edit invalidates it. First run compiles + caches; later runs
   fork the image (~10ms) and skip compilation. ring-app: 6s -> ~1s warm,
   competitive with the JVM. JOLT_NO_DEPS_CACHE disables.

Full gate green (all -m integration tests now exercise the cache); deps-image
invalidation verified (touching a source recompiles).
2026-06-14 18:21:48 -04:00
Dmitri Sotnikov
cf271e218a
Merge pull request #101 from jolt-lang/fix-record-vector-pred
Records are not vectors: fix vector?/sequential? for shape-recs (jolt-14k)
2026-06-14 22:21:17 +00:00
Yogthos
8a3019a64e Records are not vectors: vector?/sequential? false for shape-recs (jolt-14k)
Under direct-linking a record is a Janet tuple (its shape-rec), and core-vector?
just delegated to jvec? which is true for any tuple — so (vector? a-record) and
(sequential? a-record) returned true. That broke map-destructuring of a record:
the destructure coerce treats a sequential source as & {:keys} kwargs and does
(apply hash-map x), so destructuring a record fed its entries to make-phm as a
flat kv-list and corrupted. Surfaced as reitit's router crashing on a wildcard
route ('expected integer key for tuple in range [0,5), got 5') whenever the new
direct-link default was on; minimal repro is (let [{:keys [a]} (->R ...)] ...).

Fix: core-vector? excludes shape-recs, matching Clojure (a record is not a
vector or sequential). jvec? is unchanged for internal representation dispatch.
Regression cases added to record-declared-shape-test.
2026-06-14 17:45:30 -04:00
Dmitri Sotnikov
8c29168de2
Merge pull request #100 from jolt-lang/default-direct-link
Direct-link + whole-program by default for program runs
2026-06-14 20:01:28 +00:00
Yogthos
6abfd660ce Direct-link + whole-program by default for program runs; open for the REPL
Running a program is a closed world — every namespace is required, then it runs
to completion — so make it direct-link by default (inlining, record shapes, the
inference's specialization), and for a -m/-M entry auto-enable the whole-program
cross-namespace inference pass. A decomposed multi-namespace program was ~3.7x
slower than the same code in one namespace purely because per-namespace
inference can't see a caller in a not-yet-loaded namespace; this closes that for
the common case with no flags and no hints.

Interactive modes (repl, -e, nrepl-server) stay indirect/open — they have to let
you redefine vars, which direct-linking seals against. Opt-outs:
JOLT_NO_DIRECT_LINK forces the open path even for a program run (hot-reload,
runtime redefinition); JOLT_NO_WHOLE_PROGRAM keeps direct-linking but per-ns;
JOLT_DIRECT_LINK / JOLT_WHOLE_PROGRAM still force-on. Namespaces required inside
-main (after the batch pass) fall back to per-ns inference.

The success checker (RFC 0006) rides on the inference for free, but a casual
program run shouldn't spam type warnings just because it now direct-links, so its
default-on is suppressed when direct-linking was auto-enabled (:direct-link-auto?);
an explicit JOLT_DIRECT_LINK or JOLT_TYPE_CHECK still turns it on. whole-program-
test and devirt-test opt their per-ns baseline out of the new auto-default.

Docs: RFC 0005 gains 'Compilation modes and defaults' + 'Cross-namespace
inference'; RFC 0004 documents cross-ns/param hints; self-hosting-compiler and
--help updated. Full gate green.
2026-06-14 15:44:01 -04:00
Dmitri Sotnikov
230a0c2160
Merge pull request #99 from jolt-lang/field-types
Records across namespaces: field type hints, cross-ns hints, whole-program var const-linking
2026-06-14 16:58:30 +00:00
Yogthos
1525c7adfb Record type hints resolve across namespace boundaries (fields and params)
A ^RecordType hint only resolved against the current namespace's ctor key, so a
hint naming a record defined in another namespace degraded to :any. That made a
decomposed multi-namespace program much slower than the monolith: per-namespace
inference can't see a record param's callers in other namespaces, and the
declared hint that could have typed it was dropped.

Resolution now works cross-namespace, for both record FIELD hints (defrecord)
and fn PARAM hints, in both spellings — ^Vec3 where the type is referred and
^v/Vec3 where the namespace is aliased:

- reader keeps a tag's namespace qualifier (^t/Ray -> "t/Ray", was "Ray").
- make-deftype-ctor-impl indexes each ctor closure by value; record-hint-ctor-key
  resolves a hint name against the COMPILE ns (referred names live there; aliases
  resolve through it) and maps the type var's root back to its home ctor key.
  Using the ctor value, not the var's :ns, is what makes :refer work — :refer
  re-interns a fresh var whose :ns is the referring ns.
- the analyzer captures record param hints as arity :phints [name ctor-key];
  reinfer-def seeds those param types, so a record param is typed even with no
  inferred caller — the open-world / cross-ns case.

Effect on the multi-namespace ray tracer: per-ns compile 30.4s -> 7.9s with
param hints, matching whole-program (8.1s) and the single-ns monolith (8.3s).
cross-ns-hints-test covers field + param hints, refer + as, and the reader tag.
2026-06-14 12:40:14 -04:00
Yogthos
4018eb87ed Const-link stable vars under whole-program; direct-link cfunction roots
direct-var? now treats a cfunction root the same as a function root, so a
call/ref to a native fn (clojure.math/sqrt et al.) embeds the value instead of
a per-call cell deref. This was the hot indirection in the ray tracer — sqrt
runs every bounce — and it applies in every direct-link build, not just
whole-program.

const-link? is new and whole-program-only: in a closed world every non-dynamic
var has a stable root, so embed it as a constant (quoted unless it's already
callable) rather than reading the cell each reference. Covers what direct-var?
can't — ^:redef vars (reloading is off under the flag), data defs, and record
type/ctor roots. Dynamic vars stay indirect; a nil (not-yet-defined) root stays
indirect and the whole-program re-emit picks it up once the root is in place.

Measured on the records ray tracer: hot-path indirect refs (sqrt + data vars)
gone; the only indirect refs left are cold defrecord self-references. whole-
program-test now also checks a ^:redef fn and a data def so the per-ns vs
whole-program comparison guards const-link soundness.
2026-06-14 11:03:28 -04:00
Yogthos
840f699f54 record field type hints -> per-field value types in inference (jolt-3ko)
A record field can carry a type hint — ^Vec3 (a defined record type) or ^:num —
and the inference now resolves it so reading the field back yields that exact type
instead of :any. A Vec3 stored in a Ray field reads out as Vec3, so the vec ops on
field-read values prove their reads (bare-index). This is Stalin's per-slot type
sets, but DECLARED rather than inferred: the exact shape is known up front.

- deftype captures each field's :tag / :num metadata (was stripped) and passes it
  to make-deftype-ctor; the ctor registers per-field tags, resolving a record-type
  hint to its ctor-key (same-ns) so the inference can look it up directly.
- call-ret-type builds a record's struct type with field types resolved from the
  hints, recursing into nested record types (depth-bounded for self/cyclic types).

Measured: a nested-record read loop (:r (:origin ray)) runs 1.3s with ^Vec3 hints
vs 7.1s without — 5.5x. This is the lever the ray tracer needed (vecs flow through
container fields); records without it read back as :any and stay unproven.
2026-06-14 07:00:29 -04:00
Dmitri Sotnikov
872cea1c37
Merge pull request #98 from jolt-lang/stalin-techniques
Devirtualize protocol dispatch on known record receivers (jolt-41m)
2026-06-14 10:44:04 +00:00
Yogthos
fa02c8f93d devirtualize protocol dispatch on known record receivers (jolt-41m)
A protocol method call compiles to (protocol-dispatch proto method this rest) — a
runtime registry walk (type-tag -> proto -> method) on every call, ~19x a direct
call. When the inference proves the receiver (arg 0) is a known record type, the
call now resolves to a DIRECT method call at compile time, skipping the registry.

- defprotocol registers each method's var-key 'ns/method' -> [proto method] (a
  ctx-capturing register-protocol-methods! emitted into the do-block); infer-unit!
  feeds it to the inference via a box (like record-shapes).
- the record-ctor return type carries :type (the record tag) so the inference
  knows the receiver type; the :else invoke case annotates a protocol call whose
  arg0 has a known :type with :devirt-{type,proto,method}.
- emit-invoke resolves the impl via find-protocol-method at emit time and emits a
  direct call to the embedded impl fn value. Unknown/polymorphic receivers (no
  proven :type) fall back to the dispatch path unchanged.

Measured: removes the dispatch overhead (14.7s -> 9.3s on a 10M-call loop); the
remaining cost is the method body itself (non-inlined, unproven reads) — inlining
the resolved method is the follow-up (jolt-t6r) toward direct-call speed.

Sound under the closed-world assumption direct-linking already makes (the impl is
resolved + embedded at compile time). Adds devirt-test (subprocess: dispatched ==
devirtualized across polymorphic dispatch, unknown-receiver fallback, and
heterogeneous collections). Stalin's compile-call/callee-environment is the model.
2026-06-14 03:33:48 -04:00
Dmitri Sotnikov
a83792cc51
Merge pull request #97 from jolt-lang/perf-alloc
Records with declared shapes for fast field access (jolt-t34)
2026-06-14 06:33:03 +00:00
Yogthos
75a1352d22 whole-program closed-world inference, opt-in (jolt-t34)
JOLT_WHOLE_PROGRAM (requires direct-linking) defers the per-namespace inference
and runs ONE fixpoint over every user unit at once, so param types propagate
across namespace boundaries — a non-inlined fn's record params get proven from
its callers in another unit, which the per-ns pass can't see. Sound only under
the closed-world assumption (no later eval/redefinition) the flag asserts; slow,
memory-heavy builds are the documented trade-off (the reason it's opt-in).

infer-unit! now takes one ns-name OR a list; infer-program! gathers all recorded
user namespaces and runs the existing fixpoint over the union (re-emit was already
ns-agnostic — keyed by var-key, callee-first). The evaluator defers + records each
unit under the flag; run-main triggers infer-program! after all requires, before
-main. Off by default — per-ns behaviour unchanged.

Measured: a recursive (non-inlined) cross-ns record reader runs 1.66x faster
(8.9s -> 5.3s) — params proven -> bare-index reads. NOTE: small accessor fns are
INLINED cross-ns and records carry GLOBAL declared shapes, so most record reads
are already proven without this pass; the win is for non-inlined hot fns, and it's
the foundation for future whole-program work (devirtualization, unboxing).

Adds whole-program-test (subprocess soundness: per-ns and whole-program produce
identical results on a cross-ns record program).
2026-06-14 00:25:56 -04:00
Yogthos
f5a5b25d59 records use declared-shape layout with fast field access by default (jolt-t34)
Records (defrecord/deftype) are now shape-recs in a direct-linking unit by
default — no JOLT_SHAPE flag. A record's shape is DECLARED, so the inference
proves field reads by a lookup, not fragile shape inference, and they bare-index.
Result: ~1.4x faster than the :jolt/deftype table form on a record-heavy loop
(3.9s vs 5.5s), driven by cheaper construction + proven bare-index reads.

Two gates now:
- :shapes?     — shape-recs active; records use declared-shape layout + bare
                 index reads. On with direct-linking (where the inference runs).
- :map-shapes? — also shape generic const-key maps. Opt-in (JOLT_SHAPE), because
                 shaping maps net-loses on unproven reads (measured). Records win.

- call-ret-type types a record ctor (->Name) as a struct of its declared shape,
  fed from a ctx-env registry populated at deftype; field reads on the result
  bare-index. (set-record-shapes!/set-map-shapes! wired through infer-unit!.)
- sidx reads the field's position from the :shape vector AS-IS (declared order
  for records, str-sorted for map literals) — no re-sort — so any field order
  bare-indexes correctly. The map :map case only sets :shape under :map-shapes?.
- record-shape-for interns the descriptor per (type, fields), not per type: a
  record redefined with different fields now gets a fresh descriptor instead of a
  stale one (fixes redef descriptor staleness; old instances stay valid).

Adds record-declared-shape-test (declared-order reads, incl. non-alphabetical
fields, through fn boundaries + protocol method bodies). Known pre-existing edge
case filed as jolt-wf4 (direct (:f (->R …)) read returns nil after a record is
redefined with different fields; let-bound read works; repros without shapes).
2026-06-14 00:04:15 -04:00
Yogthos
4e7b8f792f shapes: revert default-on to opt-in; faster get-or-shape inline read (jolt-t34)
Measurement (vec-heavy benchmark + read-mechanism micro-benchmarks) showed that
shape-rec'ing generic const-key maps is a net loss in a bytecode VM: an unproven
field read can't beat a native Janet struct-get (one opcode), and an inline cache
doesn't transfer to a VM jolt doesn't control. The win is real only for PROVEN
reads (bare-index beats struct) — i.e. construction-heavy or inference-friendly
code, not general map-through-fn code.

So shapes are opt-in again (JOLT_SHAPE), not defaulted on in direct-link. Kept the
get-or-shape fix: the non-proven shape read now indexes inline off the descriptor
instead of calling core-get (which was ~2.5x slower per read). :shapes? is the
single opt-in gate; image cache keys on JOLT_SHAPE + JOLT_NO_SHAPE.

Next: records with declared shapes get fast field access by default (the general
case), which is where the proven-read win actually lands.
2026-06-13 23:41:26 -04:00
Yogthos
7900173d45 type-aware record equality + assoc extension + record-shape test (jolt-t34 R3)
Record shape-recs now compare type-aware like the table form: eq-map-pairs
returns nil for a record so equality falls to deep=, which is type-aware via the
per-type interned descriptor. A record equals only a same-type record, never a
plain map (cross-type and record-vs-map were wrongly equal under JOLT_SHAPE).

assoc of a new (undeclared) key keeps the record type and grows a slot, matching
Clojure's record-extension semantics.

Adds test/integration/record-shape-test.janet locking in the runtime record
mechanism (tag, field access, virtual :jolt/deftype, type-preserving assoc,
dissoc demotion, type-aware equality, #ns.Type{...} printing).
2026-06-13 20:40:50 -04:00
Yogthos
2900d36176 records as shape-recs carrying a type tag (jolt-t34 R3)
Records (defrecord/deftype) become shape-recs whose descriptor also carries
:type, under JOLT_SHAPE (flag off keeps the :jolt/deftype table form). A record
is a Janet tuple [descriptor field0 ...] in declared field order, so the
positional ->Name ctor maps args straight to slots.

- record-tag unifies the type accessor over both reps; instance?/satisfies?/
  protocol dispatch and the . interop path go through it.
- virtual :jolt/deftype key on record shape-recs (via shape-get) keeps every
  existing (get obj :jolt/deftype) dispatch site working.
- emit-kw-lookup's non-proven fallback routes any tuple to core-get (shape
  aware), not gated on compile-time JOLT_SHAPE — core is baked without the flag
  but still receives user shape-recs.
- assoc keeps the type (same-shape in place, new key grows a slot Clojure-style);
  dissoc of a declared field demotes to a plain map; pr prints #ns.Type{...}.
- JOLT_SHAPE added to the image-cache key (it shapes core's own compiled paths).
2026-06-13 20:40:50 -04:00
Yogthos
e61a175f91 feat: completeness preservation — shapes survive cap and same-shape joins (jolt-t34 R2)
The inference dropped the complete :shape whenever it rebuilt a struct type
(cap) or joined two (join-t/merge-fields), so a vec3 retrieved from a container
or a fn param typed across call sites lost its layout and every field read fell
to the slow descriptor path. Two fixes:

- cap preserves :shape: capping truncates field VALUES below the depth limit but
  never the key SET, so the layout is still complete. It also recurses into
  fields, so a shaped value nested in a container (a vec3 inside a hit-info)
  keeps its own :shape — which is what lets (:r (:normal hit-info)) bare-index.
- join-t preserves :shape when both sides are the SAME complete shape (the
  merged struct has the same keys); different shapes still drop it. This carries
  the shape through if-joins and the inter-procedural fixpoint's call-site joins.

Result: the ray tracer goes from 22s (R1, correct-but-descriptor-path) to 4.36s
— 2.7x FASTER than the 11.7s no-shape baseline, and ~3x the JVM (was 8.5x), with
byte-identical output. The compounding of cheaper tuple construction plus
bare-index reads across the whole render far exceeds the per-op estimate.

Gate green flag-off, suite 4718, default-path bench even, transparency intact.
2026-06-13 20:40:50 -04:00
Yogthos
c6b964c1b1 feat: general shape-record mechanism — consistent representation + transparency (jolt-t34 R1)
Removes ALL hardcoding. Every constant-key map literal in user code becomes a
shape-rec (a Janet tuple [descriptor v0 v1 ...]); the descriptor is interned
per key SET with a single canonical key order owned by the runtime
(types/shape-sort), so every site that builds or reads a shape agrees.

Consistency is the foundation: shape-recs are made UNCONDITIONALLY for
const-key user maps (emit-map), not gated on the inference — so a value's
representation always matches what the type system claims, which was the bug
that broke the earlier generalization (a ray built as a struct but bare-indexed
as a shape). Gated on :inline? so it applies to user data only, never to core
or the compiler's own IR-node maps.

Transparency layer (the shape-rec block now lives in types.janet, reachable by
core + evaluator + backend): get, assoc, dissoc, count, contains?, map?, first,
seq, the central realize-for-iteration normalizer (keys/vals/reduce-kv), eq-map-
pairs + eq-seqable (equality), pr-render (print), jolt-call (compiled IFn), and
the interpreter's coll-lookup all handle shape-recs. A new spec
(shape-transparency-test) asserts each op matches the equivalent struct map,
including nil/false values (which shape-recs store positionally — unlike
structs).

The ray tracer renders byte-identically (mean 122.04). Gate green with the flag
off, suite 4718. NOT yet faster — every field read currently takes the
descriptor path because the inference drops the complete :shape through joins
and containers; that's Round 2 (completeness preservation). All behind
JOLT_SHAPE (off by default).
2026-06-13 20:40:50 -04:00
Yogthos
e377c223b6 wip: generalize shape mechanism off the hardcoded vec3 shape (jolt-t34)
Removes the {:r :g :b} hardcoding. ANY constant key set is now a shape:
- inference: a struct type from a map LITERAL carries :shape (its canonical
  str-sorted key vector — completeness); joins/access-inferred structs lack
  it, so they never get a bare index. The literal node and lookup subjects
  carry the shape; the back end derives the index from it.
- backend: emit-map turns any shape-tagged const-key map into a shape tuple;
  emit-kw-lookup reads the field by bare index when the complete shape is
  proven, else by the value's own descriptor (so a shape-rec whose :shape was
  dropped by a join still reads correctly).
- runtime: core-get and core-assoc handle shape-recs.

Status: CORRECT for direct field access, container round-trips, and assoc
(minimal repros pass). NOT yet complete — the full ray tracer still hits an
uncovered path (a shape-rec reaching a map op without coverage: keys/vals/
count/seq/equality/print/jolt-call/dissoc/contains?/the interpreter's
coll-lookup all still need shape-rec branches). And the perf win needs
COMPLETENESS PRESERVATION through joins/containers (merge-fields/cap drop
:shape today, so nested vec3 access falls to the descriptor path, slower than
a struct get) — without it the general version is slower than the vec3
prototype.

All behind JOLT_SHAPE (off by default). Gate green with the flag off, suite
4718. This preserves the general design; the transparency layer + completeness
preservation are the remaining multi-session work.
2026-06-13 20:40:50 -04:00
Yogthos
d33fb85041 prototype: shape-record representation for vec3 maps (jolt-t34, JOLT_SHAPE)
Validated prototype of the hidden-class object-model change. A vec3-shaped
{:r :g :b} map literal is represented as a cheap Janet tuple [shape vb vg vr]
instead of a struct (~2x cheaper to construct); a lookup on a value the
inference PROVES is the shape reads by bare index with no runtime check.

Result on the ray tracer (direct-link): 12.3s -> 10.7s (~13% faster), with
byte-identical pixel output. The shape value flows transparently through
hit-info/ray/material containers and the colors vector; core-get handles it
(inline check, no fn call) so an unspecialized access is still correct.

Key lessons baked in: the lookup MUST compile to a bare index (a runtime
shape check, even inlined, taxed every field read and made it 2.5-3.4x
SLOWER) — so the inference gained a :shape hint (struct type with keys
exactly {:r :g :b}) that the back end turns into (in m idx). The descriptor
is quoted when embedded (its keys are a parens tuple Janet would otherwise
try to CALL).

All behind JOLT_SHAPE (off by default). Gate green, suite 4718, default-path
bench even. Scoped to the one shape; NOT yet sound in general (assumes every
vec3-shaped value is a shape-rec, true under the flag for the ray tracer) nor
fully transparent (only core-get + the inlined lookup; jolt-call/equality/
print/keys not yet covered). Those are the next steps toward a real feature.
2026-06-13 20:40:50 -04:00
Dmitri Sotnikov
50d8b13ca3
Merge pull request #96 from jolt-lang/migratus-fixes
remove logging from core
2026-06-14 00:39:48 +00:00
Yogthos
92df2cebf7 remove logging from core 2026-06-13 20:23:18 -04:00
Dmitri Sotnikov
52bea0b620
Merge pull request #95 from jolt-lang/migratus-fixes
Migratus fixes
2026-06-13 23:13:43 +00:00
Yogthos
cf1fdfdb24 feat: run the real clojure.tools.logging (defmacro/syntax-quote/ns + host shims)
Pivot from a jolt reimplementation to running the upstream library verbatim.
Vendors the real clojure/tools/logging.clj; jolt provides the backend and the
host primitives it needs. Language features (broadly useful for real Clojure
libs), all covered in 3-mode conformance + spec suites:

- defmacro: multi-arity dispatch (jolt-q8l) and a docstring + attr-map + params
  head (jolt-qnr) — the 4-arity log macro and every level macro need these.
- syntax-quote resolves an alias-qualified symbol to its target ns (jolt-9av),
  so a macro template (impl/get-logger) resolves at the use site.
- the ns macro unwraps ^{:map} metadata on the ns name (jolt-8w2 workaround,
  matching def/defn/defmacro).
- a namespace object self-evaluates, so ~*ns* can be spliced into a template.

Host shims (ported from / modeled on clojure where applicable):
- clojure.string/trim-newline (ported, CharSequence interop -> count/subs)
- agent/send-off/send (minimal synchronous stubs; jolt has no thread pool/STM)
- clojure.lang.LockingTransaction/isRunning -> false
- a minimal clojure.pprint (pprint/with-pprint-dispatch/code-dispatch, for spy)
- clojure.tools.logging.impl: a jolt stderr LoggerFactory backend (the library's
  designed pluggable extension point)

docs/libraries.md lists tools.logging; grammar.ebnf metadata note clarified.
Conformance 355/355 x3 modes; full jpm test gate green.
2026-06-13 18:50:53 -04:00
Yogthos
72e36f46de test+docs: spec coverage for migratus-enablement fixes; list migratus
Adds spec/conformance coverage for everything landed enabling migratus:
- conformance corpus (runs interpret/compile/self-host): def 3-arg docstring,
  def/defn ^{:map} name meta, defmacro arity-clause + docstring, defmulti
  docstring, assoc-nil/assoc-in real maps, try multi-body + finally-on-success,
  current-ns restore after a caught throw, cross-ns methods visibility.
- spec suites: host-interop (exception ctors, Character/Thread/Long, Timestamp/
  SimpleDateFormat, java.io.File model + File-aware file-seq, clojure.tools.logging),
  regex (Pattern statics + MULTILINE + quote, String .matches/.replaceAll/
  .replaceFirst), maps (assoc on nil), multimethods (defmulti docstring +
  value-based methods/get-method), macros (defmacro arity-clause + name meta).

Rewrote clojure.tools.logging/spy as a single variadic arity (jolt defmacro
takes only the first clause of a multi-arity macro — jolt-q8l).

docs/libraries.md: add migratus and the next.jdbc compatibility layer, with the
janet-lang/sqlite3 int64 caveat for 14-digit timestamp ids.

Full gate green; conformance 350/350 x3 modes.
2026-06-13 17:45:05 -04:00
Yogthos
4f59958d92 fix: try/catch/finally correctness + current-ns restore (jolt-96m + try bug)
Two try bugs that blocked migratus's migrate (run/table-exists? are multi-body
try/catch/finally with janet-interop jdbc calls, which punt to the interpreter):
- The interpreter's try took only form 1 as the body, dropping later body forms
  before the clauses, and did not run finally on the success path when a catch
  was present. Rewritten to collect every body form before the first
  catch/finally and to always run finally (via defer, so it fires even if a
  catch body throws).
- current-ns leaked after a caught throw from an INTERPRETED fn (it sets ns to
  its defining ns and can't restore on unwind). The interpreter's try now
  restores; the compiled try (emit-try) snapshots the ns at entry and resets it
  in the catch via new __current-ns/__set-current-ns! core fns. Statement
  values also get a :close key so with-open can close them.

With these, migratus migrate/rollback round-trips on jolt (verified end to end
against sqlite). Conformance 335/335 x3, full gate green.
2026-06-13 17:16:40 -04:00
Yogthos
eec3edf632 test: fallback-zero uses a resolvable multifn for get-method/methods
methods/get-method now take the multimethod VALUE (Clojure semantics), so the
arg must resolve to compile. The isolated analyzer never defines mf, so point
these two at print-method (a real defmulti) instead.
2026-06-13 16:11:27 -04:00
Yogthos
b304c43333 feat: java.io.File model + multimethod/assoc/defmulti fixes for migratus (jolt-hjw)
File API (jolt-hjw): io/file and (File. …) build a tagged :jolt/file value
(instance? File true) with a full method surface (isFile/isDirectory/exists/
getName/getPath/getAbsolutePath/listFiles/toPath/delete/createNewFile/…) backed
by os/ and file/. file-seq is File-aware (leaves are File values). str/slurp/spit
coerce :jolt/file to its path. ClassLoader/getSystemClassLoader + a classloader
stub whose getResource returns nil degrade migratus's classpath lookup to the
filesystem. java.nio.file Path/FileSystem/PathMatcher are shimmed just enough for
script-excluded?'s glob (recursive * / ? matcher).

Three bugs found getting migratus's migration discovery to work:
- (assoc nil k v) returned a raw janet table, not a map, so assoc-in built tables
  that count/seq rejected. Now returns an immutable map.
- methods/get-method resolved the multimethod symbol at runtime in the current
  ns, so a bare multifn ref in its defining ns saw an empty table once defmethods
  lived elsewhere. Now they take the multimethod VALUE and recover the var via a
  registry (Clojure semantics).
- defmulti now drops a leading docstring/attr-map (migratus's multimethods carry
  docstrings) instead of treating the docstring as the dispatch fn.

Conformance 335/335 x3, clojure-test-suite at baseline.
2026-06-13 16:04:30 -04:00
Yogthos
9813186ef9 fix: def supports the 3-arg docstring form (def name doc value) in compile mode (jolt-6ym)
The analyzer always took (nth items 2) as the value, so (def x "doc" 42)
bound x to the docstring and dropped 42. Now it mirrors the interpreter:
when there are 4+ items and item 2 is a string, item 2 is the docstring
(attached as :doc meta) and item 3 is the value. Conformance 335/335 x3.
2026-06-13 15:41:59 -04:00
Yogthos
19505a5944 feat: jolt-side java.sql interop + defn/defmacro meta & arity-clause fixes
For the migratus next.jdbc shim (jolt-0z5):
- core.janet: __jdbc-wrap-conn / __jdbc-conn-raw / __jdbc-make-stmt builtins.
  A connection is a tagged wrapper over a jdbc.core conn carrying a clj :exec
  callback so the host Statement.executeBatch runs SQL without a janet->clj call.
- javatime.janet: tagged-methods for :jolt/jdbc-conn (setAutoCommit/isClosed/
  close/getMetaData), :jolt/jdbc-meta (getDatabaseProductName), :jolt/jdbc-stmt
  (addBatch/executeBatch/close); java.sql.Timestamp ctor -> millis.
- evaluator.janet: instance? case for Connection/java.sql.Connection so
  migratus's do-commands runs SQL through its Connection branch.

Two defn/defmacro fixes found loading migratus.core (both rooted in the reader
representing ^{:map} name metadata as a with-meta form, jolt-8w2):
- defmacro special form: unwrap a with-meta name (mirrors def), and handle the
  arity-clause form (defmacro name ([params] body...)) like fn/defn — a params
  vector reads as a tuple, an arity clause as a list (array).
- defn overlay: pass the bare (unwrapped) name to fn while def keeps the meta.

Conformance 335/335 x3 modes.
2026-06-13 15:28:54 -04:00
Dmitri Sotnikov
30267fe67a
Merge pull request #94 from jolt-lang/perf-type-inference
Perf type inference
2026-06-13 19:12:39 +00:00
Yogthos
e2d33df484 feat: clojure.tools.logging shim for migratus (jolt-nzg)
Faithful shim of the real clojure/tools.logging public API rather than a
bespoke logger. clojure.tools.logging.impl provides the Logger/LoggerFactory
protocols (matching upstream signatures) plus a jolt stderr-backed factory;
find-factory returns it instead of probing slf4j/log4j/jul. The macro surface
(logp/logf, level macros + f-variants, log, enabled?, spy) dispatches through
those protocols. Departures forced by no-JVM: log* writes directly (no agent/
LockingTransaction), and the throwable-first-arg branch is dropped since
(instance? Throwable x) is always false for jolt exception values.

Workarounds for two jolt gaps found en route (filed as jolt-9av, jolt-6ym):
macro bodies fully-qualify impl refs because syntax-quote does not resolve ns
aliases, and def docstrings are kept in comments because (def name doc value)
is mishandled.

stderr via __eprint. trace/debug suppressed by default (impl/*level* :info).
Conformance 335/335 x3 modes.
2026-06-13 14:58:47 -04:00
Yogthos
f9a1849ec8 docs: RFC 0006 — mark negative/never types resolved (jolt-wwy) 2026-06-13 14:48:39 -04:00
Yogthos
328f88636e feat: negative/never types — calling a non-function, wrong-arity (jolt-wwy)
Two provably-wrong cases the inference already has the facts for, closing the
last RFC 0006 open question:

- Calling a non-function. At an :invoke whose callee is provably :num or :str
  (the only non-callable types — keywords/maps/vectors/sets are IFn), report
  "cannot call a number as a function". Default level (no closed-world: the
  callee type is inferred at the call site). Covers (5 1), ("hi" 0),
  ((+ 1 2) :k), a let-bound number, and a var holding a number (via vtype-box
  in direct-link). A union is non-callable only when every member is, so
  ((if c 1 :k) x) is accepted (:kw is callable). Verified zero false positives
  on the ray tracer, which calls maps/keywords/vectors as fns throughout.

- Wrong arity to a user fn. The registered single-fixed-arity sig (jolt-zo1)
  makes a mismatched arg count provably throw; reported under the
  JOLT_TYPE_CHECK_USER opt-in (same closed-world boundary; ^:redef/variadic
  skipped). Caught at compile time before the runtime arity error.

Both fold into the existing infer walk, carry :pos for file:line:col, and keep
no-false-positives. Gate green, suite 4718, conformance 335/335, runtime bench
even (compile-time only).
2026-06-13 14:48:14 -04:00
Yogthos
6abcb11e92 feat: misc host-interop shims for migratus (jolt-3v0)
Character/isUpperCase + isLowerCase (ASCII, on :jolt/char structs);
Thread/interrupted (false, no real threads) + Thread/currentThread stub
with a .getContextClassLoader classloader stub; Long/valueOf; java.net.URI
constructor (string round-trip); and a minimal java.util.Date / TimeZone /
SimpleDateFormat supporting the y M d H m s tokens migratus's timestamp uses,
backed by os/date (UTC default). Full gate + 3-mode conformance green.
2026-06-13 14:00:50 -04:00
Yogthos
4e3984e9c0 feat: host-interop shims for migratus (exceptions + regex Pattern)
jolt-6xk: resolve bare exception class symbols (Exception,
IllegalArgumentException, InterruptedException, Throwable) by consulting
class-ctors/class-canonical-names in the unqualified symbol-resolution
fallthrough, mirroring the qualified path. Constructors already existed
in javatime; throw/catch/.getMessage already handled the string payload.

jolt-47b: java.util.regex.Pattern statics (compile/quote/MULTILINE) that
return jolt's native :jolt/regex value so str/replace, re-matches, and
.split accept them transparently. MULTILINE maps to a (?m) prefix routed
through the regex engine's inline-flag path; the engine gains (?m) anchor
support with the non-multiline branches left byte-identical. String
methods .matches/.replaceAll/.replaceFirst added. .split dispatches on
compiled-regex via a :jolt/regex tagged-method.

Full jpm test gate green.
2026-06-13 13:49:42 -04:00