Rename src/jolt -> stdlib (the runtime-loaded layer; jolt-core stays the seed-baked layer) and update the loader / emit-image / doc paths. Drop dead code: the spike/ experiments, the duplicate clojuredocs-export.edn (json moves to tools/), the Janet-era jolt.http binding, and the orphaned persistent_vector.clj whose ns/path didn't even match. Strip porting residue from comments and docstrings across host/chez, jolt-core, stdlib, tests, and docs: internal issue ids, "Phase N" markers, and the "vs Janet" historical exposition, leaving present-tense descriptions and the real JVM-Clojure semantic contrasts. Same pass over the corpus suite labels. The seed is unchanged (docstrings/comments aren't emitted), so the self-host fixpoint and corpus are untouched. Port tools/spec_coverage.py off the dead janet probe to bin/joltc and regenerate coverage.md; drop the dead :host/janet rule from certify.clj and regenerate the conformance profile. Add docs/host-interop.md (the JVM shims and how to register your own host class from a library) and a writing-style note in CLAUDE.md. Stabilize the four racy concurrency corpus cases (future-cancel and agent send/send-off): give the future a sleeping body and the agent a slow action, so cancel reliably catches an in-flight future and deref reliably reads the pre-update snapshot. They certify deterministically now, so drop their :flaky allowlist entries and the orphaned legend.
15 KiB
RFC 0005 — Structural collection-type inference
- Status: Implemented. Ray tracer 12.8s to 11.0s hint-free,
matching the explicit
^:structversion; render checksum unchanged. - Champions: jolt maintainers
- Created: 2026-06-13
Summary
Replace jolt's ad-hoc inference lattice with a single recursive structural
type, so that the type of a value mirrors the tree shape of the data it
describes. A struct-map carries its field types, a vector its element type, a
function its parameter and return types, recursively. A keyword lookup returns
the looked-up field's type, so nested access like (:r (:direction ray)) is
typed end to end. This unifies the two facts the current inference tracks
inconsistently (a vector's element type, but not a map's field types), subsumes
the existing inference passes as special cases, and
closes the remaining ray-tracer gap without a hint. The system is a
soft-typing-style inference: it never rejects a program, it assigns a concrete
type only when it can prove one, and it falls back to :any (and the existing
runtime guard) everywhere else.
Motivation
The existing inference specializes a collection access (drops the
:jolt/type guard, emits pv-count, and so on) when it can prove the
collection's type. It works, it is sound, and it is fully dynamic-fallback
safe. But its type lattice grew ad hoc:
:struct-mapmeans "a raw-get-safe map" but carries no field types.{:vec ELEM}carries its element type.
These are the same idea applied to two kinds of child in the data tree, but
only one is tracked. The cost is concrete: in the ray tracer a lookup result
like (:direction ray) is typed :any, so (:r (:direction ray)) keeps its
guard, and the vec3 functions (called all day with such values) cannot be
typed, so the inference reaches only about 3% where the explicit ^:struct
hint reaches 22%. The hint wins precisely because it asserts the field/param
shape the inference fails to derive.
The fix is to make the type a structural tree, tagged as precisely as provable.
Then :struct tracking and field tracking are one mechanism, the special cases
collapse into one signature table, and nested access is typed by construction.
The type lattice
A type T is one of:
- A scalar tag:
:num,:str,:kw,:bool,:char. (Optionally a coarser:nonnilfor "provably not nil and not false", which is what the struct-vs-phm decision needs; see below.) :nil.{:struct {field -> T}}— a raw-get-safe map (a record) whose fieldkhas type(fields k)or:anyif absent. The degenerate{:struct {}}is "a struct, fields unknown" and replaces today's:struct-map.{:vec T}— a vector whose elements have typeT.{:set T}— a set ofT.:phm— a persistent hash map (NOT raw-get-safe; distinct from:struct).{:fn {:params [T...] :ret T}}— a function (optional precision; the current flat param/return inference is the zero-arity-detail version of this).:any— the top. Anything not provably more specific.:bottom(represented as the absence of a type /nilinternally) — the identity for join, used to seed the fixpoint.
Types are immutable values comparable by structural equality, exactly like the
current {:vec ELEM} representation, so they flow across the portable
inference and the host unchanged.
Join (least upper bound)
join(T, T) = T
join(bottom, T) = T
join({:struct a}, {:struct b}) = {:struct {k -> join(a[k]?:any, b[k]?:any) for k in keys(a) ∪ keys(b)}}
join({:vec a}, {:vec b}) = {:vec join(a, b)}
join({:set a}, {:set b}) = {:set join(a, b)}
join(_, _) = :any ; different constructors
Two struct types join field-wise; a field present in only one side becomes
:any in the result (it might be absent, so a lookup of it is not provably
typed). This is the standard record lattice.
Termination: depth cap
Structural types of recursive data (a tree node that contains a tree node, a
cons cell) would be infinite. To keep types finite and the inter-procedural
fixpoint terminating, structural types are depth-capped: beyond a small
depth D (proposed D = 4) a child type is :any. Construction and join both
truncate at D. With the cap the lattice has finite height, so the monotone
fixpoint converges. The ray tracer's shapes (vec3 inside ray inside hit-info)
are depth 2 to 3, well inside the cap.
Inference rules
Inference is a forward pass producing [type node'] for each IR node (the
existing shape), threaded with a local type environment and the
inter-procedural state. The rules are uniform over the structural
type:
- Literals.
{:k v ...}with constant scalar keys and struct-safe values builds{:struct {:k type(v) ...}}; otherwise:phm.[a b ...]builds{:vec (join type(a) type(b) ...)}.#{...}builds{:set ...}. Scalars build their scalar tag. (The struct-vs-phm condition is the same as the back end's: scalar keys, and every value provably non-nil and non-false.) - Lookup returns the field type.
(:k m)/(get m :k)wherem : {:struct fs}returns(fs :k)or:any. This is the single rule that makes nesting work and that unifies field tracking with:structtracking. - Indexing returns the element type.
(nth v i)/(v i)wherev : {:vec T}returnsT.(first v)/(peek v)likewise. - Flow.
let/loopbind init types;ifjoins the branch types;dotakes the tail type. (As today.) - Calls use signatures. Every call result type comes from the callee's signature: core fns from a fixed signature table (below), user fns from the inter-procedural fixpoint's inferred signature.
The inter-procedural fixpoint, recompile, escape gate, and closed-world assumption are unchanged. They now propagate structural types instead of flat tags.
Core function signatures
The current special cases (truthy-ret-fns, vector-ret-fns, elem-fns,
hof-table, and the conj/range/reduce/mapv branches) collapse into one
table of type schemes, possibly parametric:
inc, dec, +, -, *, /, mod, ... : (... :num) -> :num
count : (Coll) -> :num
nth : ∀T. ({:vec T}, :num) -> T (3-arg adds a default: -> join(T, default))
get : ∀T. ({:struct fs}, :k) -> (fs :k) ; const key
first,peek : ∀T. ({:vec T}) -> T
conj : ∀T. ({:vec T}, x) -> {:vec join(T, type(x))}
assoc : ({:struct fs}, :k, v) -> {:struct (assoc fs :k type(v))} ; const key
vec, mapv : ... -> {:vec ...}
range : (...) -> {:vec :num}
rand-nth : ∀T. ({:vec T}) -> T
map, filter, mapv, filterv, reduce, ... ; see HOFs
Parametric schemes (the ∀T) are where polymorphism actually matters, and they
give the element/field propagation for free. Higher-order functions are just
schemes whose parameter is itself a function type: reduce's scheme says its
function argument is (Acc, Elem) -> Acc applied to the collection's element
type, so the closure's element parameter is typed by applying the scheme,
replacing the hand-written hof-table.
Hints as seeds
^:struct x seeds x : {:struct {}} (a struct, fields unknown) at a unit
boundary the inference cannot see across. A future extension could allow a shape
hint ^{:r :num :g :num :b :num} to seed field types, but once inference is
structural this is rarely needed; the hint stays the escape hatch for genuinely
dynamic boundaries, exactly as today.
Soundness
Unchanged in spirit from the current system: a concrete type is assigned only
when proven (a literal genuinely has those fields; a fn provably returns that
shape), and everything unprovable is :any, which keeps the dynamic guard. A
wrong specialization is therefore impossible. The inter-procedural part keeps
the closed-world (optimization-mode) assumption already adopted, which is sound
under whole-program / source-distribution compilation.
Compilation modes and defaults
Direct-linking — and the inference and specialization it enables — is the default for running a program and stays off for interactive work, chosen by the CLI run mode rather than a global opt-in flag:
| mode | linking | whole-program |
|---|---|---|
-m / -M NS (program entry) |
direct (default) | auto (closed world) |
FILE / -f / stdin (-) |
direct (default) | no (per-namespace) |
repl, -e, nrepl-server |
indirect / open | no |
A program run is a closed world — every namespace is required, then the code
runs to completion — so it direct-links: user code gets inlining, record shapes,
and the inference's specialization. A -m / -M entry is the exact point where
all requires are done and -main is about to run, so the whole-program
cross-namespace pass (below) runs there automatically. Interactive modes stay
open: a REPL, -e, and the nREPL server must let you redefine vars — which
direct-linking seals against — so they keep the indirect, live-deref path.
Env overrides, all winning over the mode default:
JOLT_NO_DIRECT_LINK=1— force the open/indirect path even for a program run (runtime redefinition, hot-reload, self-modifying code).JOLT_NO_WHOLE_PROGRAM=1— keep direct-linking but skip the whole-program pass (per-namespace inference only).JOLT_DIRECT_LINK=1— force direct-linking on even in an interactive mode.JOLT_WHOLE_PROGRAM=1— force the whole-program pass on in any direct-linked mode.JOLT_NO_SHAPE=1— disable the record/shape representation under direct-linking.
What direct-linking gives up is what Clojure's :direct-linking and jank's
-Odirect-call give up: a direct call embeds its callee, so redefining the
callee is not seen by already-compiled callers. Whole-program additionally
const-links stable vars (data defs, record types, ^:redef), extending the same
trade. That is why the interactive modes stay open and the opt-outs exist.
Cross-namespace inference
Per-namespace inference (a FILE run, or any namespace under
JOLT_NO_WHOLE_PROGRAM) types a function's parameters from the call sites it can
see within that namespace. A function whose record parameter is supplied by a
caller in another namespace is left :any, its field reads keep the guard, and
the values derived from it widen — so a decomposed program is markedly slower
than the same code in one namespace (measured at ~3.7× on the ray tracer split
across five namespaces). The information exists in the program; per-namespace
compilation just can't see a caller in a not-yet-loaded namespace. Two ways to
supply it:
- Whole-program (auto for
-m/-M) runs one closed-world inference fixpoint over every loaded namespace before-main, typing each parameter from its call sites wherever they live. Namespaces required later (inside-main) fall back to per-namespace inference. - Parameter type hints (
^RecordType, RFC 0004) declare the type directly, so it also works in the open world — REPL, library code that must be fast for any caller, and hot-reloading servers — where the world cannot be closed.
Relationship to Hindley-Milner and soft typing
This is HM-shaped with two deliberate departures, which is the textbook definition of soft typing (Wright and Cartwright, "A Practical Soft Type System for Scheme", 1997 — HM extended with union types and a dynamic type).
Taken from HM:
- The structural type language (records, vectors, functions as type constructors). This is the "tree of types".
- Constraint propagation and type schemes for the core library (the
∀Tsignatures). That parametric polymorphism is exactly what HM provides, and it is where it matters (generic collection functions likenth,reduce,map).
Changed, on purpose:
- Replace "unify or fail" with "join over a lattice whose top is
:any". The inference never rejects a program; an unprovable spot becomes:anyand keeps the runtime guard. This is the "fall back to dynamic when in doubt" policy made principled. - Monovariant for user functions (the inter-procedural fixpoint plus inlining cover the practical polymorphism); parametric schemes are kept only for core functions.
So: HM structural types and constraint propagation and core-fn schemes, solved by lattice join with a dynamic top instead of unification-or-fail. Other AOT inferencers for dynamic languages do the whole-program version of the same thing (RPython's annotator, Crystal's global inference, Shed Skin), all with a union/dynamic fallback.
Implementation and migration
This is a refactor that simplifies the current code: it deletes the ad-hoc tag soup and the per-op special cases and replaces them with one recursive type plus a signature table.
- Define the structural type,
join, the depth cap, and the predicates (struct-safe?,field-type,elem-type) injolt.passes. - Rewrite
inferso each op produces/consumes structural types: literals build shapes;(:k m)returns the field type; calls consult the signature table. - Move the core-fn knowledge into a signature table (subsumes the existing tables and HOF handling).
- The back end keeps reading the use-site type to specialize (guard drop for
{:struct},pv-count/pv-nthfor{:vec}), now uniformly. - Keep the inter-procedural fixpoint, recompile, escape gate, and triggering as is; they propagate structural types.
The phases land incrementally behind the same optimization-mode gate, each verified against conformance (three modes), the full test gate, and the ray-tracer benchmark, exactly as the current phases were.
Design problems and open questions
- Recursion / termination. Handled by the depth cap (
D = 4). Open question: is a fixed cap better than proper recursive (mu) types? A cap is simpler and sound; mu-types are more precise but add complexity. Proposed: start with the cap. - Compile-time cost. Structural types are larger and the fixpoint does more work. Mitigations: the depth cap bounds type size; inference runs only in optimization mode; the fixpoint iteration count stays bounded. Needs measurement on a large namespace (clojure.core itself) to confirm acceptable.
- Heterogeneous data.
[1 "a"]joins to{:vec :any}; a map whose field varies across branches joins that field to:any. Correct degradation, not a problem, but worth stating. - Non-constant keys.
(assoc m k v)/(:k m)with a non-constantkcannot track a specific field; the result degrades to{:struct {}}or:phmas appropriate. Field tracking only applies to constant scalar keys. false/nilfield values. A map literal is{:struct ...}only when every value is provably non-nil and non-false (the back end stores such maps as a phm). The:nonniltag (or a per-type "provably truthy" predicate) is what the literal rule needs; this must be carried correctly or struct inference is unsound.- Function-type precision.
{:fn ...}is optional. The current flat param/return inference is enough for the collection-specialization goal; full function types matter more for the type-checker (RFC 0006) and could be deferred. - Closed-world boundary. Inherited from the inter-procedural pass: param/return inference assumes the compiled unit is the whole program. Documented there; unchanged.