# RFC 0005 — Structural collection-type inference

- **Status**: Implemented (jolt-5uj). Ray tracer 12.8s to 11.0s hint-free,
  matching the explicit `^:struct` version; 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 phases (jolt-99x Phases 0 to 3) 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 inference added in jolt-99x 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-map` means "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
  `:nonnil` for "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 (Janet struct or record) whose
  field `k` has type `(fields k)` or `:any` if absent. The degenerate
  `{:struct {}}` is "a struct, fields unknown" and replaces today's
  `:struct-map`.
- `{:vec T}` — a vector whose elements have type `T`.
- `{:set T}` — a set of `T`.
- `: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 / `nil` internally) — 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 Janet orchestrator 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 from Phase 1. 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)` where
  `m : {:struct fs}` returns `(fs :k)` or `:any`. This is the single rule that
  makes nesting work and that unifies field tracking with `:struct` tracking.
- **Indexing returns the element type.** `(nth v i)` / `(v i)` where
  `v : {:vec T}` returns `T`. `(first v)` / `(peek v)` likewise.
- **Flow.** `let`/`loop` bind init types; `if` joins the branch types; `do`
  takes 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 Phase 1 inter-procedural fixpoint, recompile, escape gate, and closed-world
assumption (RFC to follow / jolt-767) 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:

1. **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.
2. **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
  `∀T` signatures). That parametric polymorphism is exactly what HM provides,
  and it is where it matters (generic collection functions like `nth`,
  `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 `:any` and
  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.

1. Define the structural type, `join`, the depth cap, and the predicates
   (`struct-safe?`, `field-type`, `elem-type`) in `jolt.passes`.
2. Rewrite `infer` so each op produces/consumes structural types: literals
   build shapes; `(:k m)` returns the field type; calls consult the signature
   table.
3. Move the core-fn knowledge into a signature table (subsumes the existing
   tables and HOF handling).
4. The back end keeps reading the use-site type to specialize (guard drop for
   `{:struct}`, `pv-count`/`pv-nth` for `{:vec}`), now uniformly.
5. Keep the Phase 1 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-constant `k`
  cannot track a specific field; the result degrades to `{:struct {}}` or
  `:phm` as appropriate. Field tracking only applies to constant scalar keys.
- **`false`/`nil` field 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 `:nonnil` tag (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 Phase 1: param/return inference
  assumes the compiled unit is the whole program. Documented there; unchanged.
