feat: expand type-hint lookup specialization (^Record, get-form, checked mode, docs)

Builds on the ^:struct keyword-lookup hint:

- ^TypeName for records. A tag naming a defrecord/deftype now resolves to the
  struct fast path: record instances are tables tagged :jolt/deftype (not
  :jolt/type), so a raw keyword get is correct for them. A new host contract fn
  record-type? detects a record by its ->Name constructor; a non-record tag
  (^String, ^long, ...) is ignored, as before.

- (get m :k) and (get m :k default) now get the same inlined keyword lookup as
  (:k m): the representation guard fast path when unhinted, and the bare get
  when the subject is ^:struct/^Record. A variable/number/string key still
  falls through to core-get. The two call shapes share one emitter
  (emit-kw-lookup).

- JOLT_CHECK_HINTS=1 turns a violated hint into a clear runtime error (naming
  the local and key) by keeping the guard and throwing on the tagged arm. It is
  off by default with zero cost to normal builds (a hinted lookup still emits a
  bare get), and is part of the image-cache fingerprint. This is the answer to
  "a lying hint is silent": opt into checking during development.

- Docs: RFC 0004 records the design, soundness contract, and measurements; the
  reader spec gains S12b (hints are semantically transparent; jolt recognizes
  ^:struct and ^Record as lookup-optimization assertions).

There is no Clojure keyword equivalent for "plain map / fast keyword access"
(Clojure hints are class names), so ^:struct stays a jolt-specific flag,
analogous to ^:dynamic.

Verified: conformance 335/335 in all three modes and the full jpm test pass; a
seeded ray-tracer render is byte-identical hinted vs unhinted; the struct-hint
test covers record hints, the get-form, inline propagation, and the checked-mode
error. Full render with hints holds at 13.3s -> 10.9s (1.22x).
This commit is contained in:
Yogthos 2026-06-12 20:20:25 -04:00
parent c4be5d8a0e
commit 5f59c02b69
7 changed files with 276 additions and 54 deletions

128
docs/rfc/0004-type-hints.md Normal file
View file

@ -0,0 +1,128 @@
# RFC 0004: Type hints and keyword-lookup specialization
Status: accepted (design note)
This note describes how Jolt treats Clojure type hints, and the one place it
uses them: a `^:struct` or `^Record` hint on a local lets a constant-keyword
lookup skip its runtime representation guard. It records the rationale, the
soundness contract, the checked mode for catching inaccurate hints, and the
measured effect, so later work does not relitigate it.
## Background: why the lookup carries a guard
A Jolt map value has several runtime representations (see RFC on collections and
`src/jolt/core.janet`): a Janet struct for a small all-scalar-key literal map, a
persistent hash map (a table tagged `:jolt/type :jolt/phm`) when a key is a
collection or a value is nil, plus sorted maps, transients, and record/deftype
instances. A record instance is a Janet table tagged `:jolt/deftype` but, like a
struct, it carries no `:jolt/type`, so a raw Janet `(get inst :field)` reads its
fields directly.
A constant-keyword lookup `(:k m)` compiles to a guarded form:
```janet
(if (get m :jolt/type) (core-get m k) (get m k))
```
The guard is one opcode. A non-nil `:jolt/type` routes phm/sorted/transient/
lazy-seq values to `core-get`'s full semantics; everything else (structs,
records, nil, scalars) takes the bare Janet `get`, which matches `core-get` for
keyword keys. The guard is correct and cheap, but on a struct it is a second
`get`: profiling the ray tracer (a naive all-maps program) found keyword lookups
are about half of a render, and the guard is the only avoidable part of each
one. A bare get is roughly 20ns where the guarded form is roughly 36ns.
Dropping the guard is only safe when the subject is known to be a plain
struct/record rather than a tagged collection. Jolt does not infer that
inter-procedurally (it would be unsound across a dynamic language's call
boundaries). A type hint supplies the same fact soundly, as a programmer
assertion.
## What the hints mean
Two hints on a local resolve to the "plain struct/record" assertion, which we
call the `:struct` hint internally:
- `^:struct` — the value is a plain struct or record map. There is no Clojure
keyword with this meaning (Clojure's type hints are class names), so this is a
Jolt-specific metadata flag, analogous to `^:dynamic`.
- `^Name` where `Name` is a `defrecord`/`deftype`. Both forms define a `->Name`
positional constructor, so the analyzer treats a tag whose `->Name` resolves
as a record type. Record instances are raw-get-safe, so the lookup drops the
guard. A `^String`, `^long`, or any other non-record tag is not a record and
is ignored, exactly as before.
Every other hint parses and is inert, matching Clojure (S12b in the reader
spec). A hint never changes a program's result; it only permits an
optimization.
## How it flows
The reader already keeps `^hint` metadata on the binding symbol and is otherwise
transparent (`reader.janet`, `meta-form->map`). The change threads that fact to
the lookup site:
1. The analyzer (`jolt-core/jolt/analyzer.clj`) records a `:struct` hint per
local in its env when a param or `let` binding carries `^:struct` or a
record-type tag, and attaches `:hint :struct` to that local's `:local` IR
node. Resolving a record-type tag uses a new host contract function
`record-type?` (`src/jolt/host_iface.janet`), which checks for the `->Name`
constructor.
2. The back end (`emit-kw-lookup` in `src/jolt/backend.janet`) emits the bare get
when the lookup subject is a `:local` carrying the hint, and the guarded form
otherwise. The unhinted path is byte-identical to before.
3. The inline pass (`jolt-core/jolt/passes.clj`) propagates the hint: when it
binds a non-trivial call argument to a fresh local, it carries the called
function's parameter hint onto that local, so lookups inside the spliced body
keep the bare path. Without this, inlining a hinted function would erase the
benefit, because the hinted parameter is replaced by an unhinted temporary.
The same machinery covers both `(:k m)` and `(get m :k [default])` when the key
is a constant keyword. A `get` with a variable, numeric, or string key falls
through to `core-get` unchanged.
## Soundness and the checked mode
An accurate hint is correctness-preserving by construction: for a struct or
record the bare get equals the guarded result. An inaccurate hint (asserting
`^:struct` for a value that is actually a phm) makes the raw get return the wrong
thing. This is the same contract as a wrong Clojure `^String`, except that a
wrong Jolt hint fails silently rather than throwing.
To make a lie visible without taxing the fast path, `JOLT_CHECK_HINTS=1` keeps
the guard but throws on the tagged arm with a message naming the local and key:
```
type hint violated on `m`: (:a m) — value carries :jolt/type
(a phm/sorted/transient/lazy-seq), not the plain struct/record the
^:struct/^Record hint asserts
```
This is a development aid, off by default, with zero cost to normal builds (the
flag is read when the lookup is compiled, and the bare get is emitted when it is
off). The flag is part of the image-cache fingerprint.
## Coverage
Type hints parse in every position Clojure accepts them and are inert except for
the optimization above. This matches Clojure's "parse and otherwise do nothing"
model, with the difference that Clojure additionally uses hints to avoid
reflection and select primitive arithmetic, which do not apply to a Janet host.
## Measured effect
On the ray tracer (`~/src/examples/ray-tracer`, all values are `{:r :g :b}`-style
maps), with inlining on and the hot parameters hinted, a render goes from 13.3s
to 10.9s, about 1.22x, taking it to roughly 7.8x the JVM from 9.4x after the
inline pass. A seeded render produces an identical pixel checksum hinted and
unhinted, confirming the hints are correctness-preserving on the full pipeline.
## Status and non-goals
Implemented. Not pursued: inter-procedural shape inference (unsound in a dynamic
language without a guard, which costs as much as the one being removed) and a
shape-based "hidden class" representation (profiling showed allocation is about
1% of the workload, so a cheaper allocation would not help, and an escaping-map
lookup through a runtime shape check costs about the same as the guard it would
replace). The hint is the sound, opt-in lever on the part of the cost that can
move.

View file

@ -83,6 +83,16 @@ checks → UNVERIFIED (rows to add).
- S12a. `^:kw form``^{:kw true} form`; `^Sym form``^{:tag Sym} form`;
`^"str"``^{:tag "str"} form`. Multiple `^` stack, rightmost innermost,
merged left-over-right.
- S12b. Type hints are semantically transparent: a hint MUST NOT change a
program's result. Hints parse in every position they do in Clojure (params,
`let` bindings, `def` names, return position, arbitrary forms) and are
otherwise inert. As a non-normative optimization, jolt recognizes two hints
on a local as an assertion that a constant-keyword lookup may skip its
runtime representation guard: `^:struct` (a plain struct/record map) and
`^Name` where `Name` is a `defrecord`/`deftype`. The assertion is the
programmer's (an inaccurate hint yields a wrong lookup, like a wrong Clojure
`^String`); `JOLT_CHECK_HINTS=1` turns a violated hint into an error at no
cost to unchecked builds. See RFC 0004.
- S13a. `#'ns/sym` MUST denote the same var as `(var ns/sym)`:
`(= (var clojure.core/str) #'clojure.core/str)` is true.

View file

@ -22,7 +22,8 @@
form-literal? form-elements form-vec-items
form-map-pairs form-set-items form-special? compile-ns
form-macro? form-expand-1 resolve-global
form-sym-meta host-intern! form-syntax-quote-lower]]))
form-sym-meta host-intern! form-syntax-quote-lower
record-type?]]))
(declare analyze)
@ -44,13 +45,19 @@
(defn- add-locals [env names] (update env :locals #(reduce conj % names)))
(defn- with-recur [env name] (assoc env :recur name))
;; Type hints (jolt-dad). The reader keeps ^hint metadata on the binding symbol;
;; we recognize ^:struct, which asserts the value is a plain struct/record map so
;; a constant-keyword lookup can skip the :jolt/type guard and emit a bare get.
;; Other hints parse and are ignored, as before.
(defn- hint-of [sym]
;; Type hints (jolt-94n). The reader keeps ^hint metadata on the binding symbol.
;; Two hints resolve to the :struct fast path (a constant-keyword lookup skips
;; the :jolt/type guard and emits a bare get): ^:struct (a plain struct/record
;; map) and ^TypeName where TypeName is a defrecord/deftype (its instances are
;; tagged :jolt/deftype, not :jolt/type, so a raw get is correct). Every other
;; hint (^String, ^long, ...) parses and is ignored, as before.
(defn- hint-of [ctx sym]
(let [m (form-sym-meta sym)]
(when (and m (get m :struct)) :struct)))
(cond
(nil? m) nil
(get m :struct) :struct
:else (let [t (get m :tag)]
(when (and t (record-type? ctx t)) :struct)))))
(defn- add-hint [env nm h]
(if h (assoc env :hints (assoc (:hints env) nm h)) env))
@ -69,11 +76,11 @@
(when-not (form-sym? bsym) (uncompilable "destructuring binding"))
(let [nm (form-sym-name bsym)
init (analyze ctx (nth bvec (inc i)) env)]
(recur (+ i 2) (add-hint (add-locals env [nm]) nm (hint-of bsym))
(recur (+ i 2) (add-hint (add-locals env [nm]) nm (hint-of ctx bsym))
(conj pairs [nm init]))))
[pairs env])))
(defn- parse-params [pvec]
(defn- parse-params [ctx pvec]
;; :hints is a vector of [name hint] pairs (vector, not a map, so the caller
;; folds it with a plain reduce — no reduce-over-map in the kernel subset).
(loop [i 0 fixed [] rest-name nil hints []]
@ -84,13 +91,13 @@
(let [r (nth pvec (inc i))]
(when-not (form-sym? r) (uncompilable "destructuring fn rest"))
(recur (+ i 2) fixed (form-sym-name r) hints))
(let [nm (form-sym-name p) h (hint-of p)]
(let [nm (form-sym-name p) h (hint-of ctx p)]
(recur (inc i) (conj fixed nm) rest-name
(if h (conj hints [nm h]) hints)))))
{:fixed fixed :rest rest-name :hints hints})))
(defn- analyze-arity [ctx pvec body env fn-name]
(let [pp (parse-params (vec (form-vec-items pvec)))
(let [pp (parse-params ctx (vec (form-vec-items pvec)))
fixed (:fixed pp)
rst (:rest pp)
;; Always a recur target, variadic included: the back end gives the rest

View file

@ -296,7 +296,7 @@
# Opts land in the key via their printed form; an opt that prints unstably
# (e.g. a closure in :namespaces) just degrades to a cache miss, never to a
# wrong hit. Runtime knobs that shape the ctx outside opts ride along too.
(def key (string/format "%q|%q|%q|%q|%q|%q|%q|%q|%q"
(def key (string/format "%q|%q|%q|%q|%q|%q|%q|%q|%q|%q"
(string janet/version "-" janet/build)
opts
(os/getenv "JOLT_PATH")
@ -305,7 +305,8 @@
(os/getenv "JOLT_FEATURES")
(os/getenv "JOLT_INTERPRET_MACROS")
(os/getenv "JOLT_DIRECT_LINK")
(os/getenv "JOLT_NO_IR_PASSES")))
(os/getenv "JOLT_NO_IR_PASSES")
(os/getenv "JOLT_CHECK_HINTS")))
(string dir "/jolt-ctx-" (band h 0x7FFFFFFF) "-" len "-" (band (hash key) 0x7FFFFFFF) ".jimg"))
(defn init-cached

View file

@ -304,10 +304,55 @@
(var- fp-counter 0)
(defn- jsym [] (symbol "_fp$" (++ fp-counter)))
# Is fnode a reference to clojure.core/get (or a host `get`)? Used to give
# (get m :kw [d]) the same inlined keyword-lookup treatment as (:kw m [d]).
(defn- get-head? [fnode]
(case (fnode :op)
:var (and (= "clojure.core" (fnode :ns)) (= "get" (fnode :name)))
:host (= "get" (fnode :name))
false))
# Shared emit for a constant-keyword map lookup — both (:kw m [d]) and
# (get m :kw [d]). subj-node is the subject's IR node (carries the type hint),
# m-expr its emitted form, k the keyword, d-expr the emitted default or nil.
# - unhinted: GUARDED — (if (get m :jolt/type) (core-get …) (bare get)). The
# guard (one opcode) routes tagged reps (phm/sorted/transient/lazy-seq) to
# core-get; a plain struct/record (no :jolt/type) takes the bare get, which
# matches core-get for keyword keys.
# - ^:struct / ^Record hinted subject: skip the guard, bare get (~20 vs ~36ns).
# - hinted + JOLT_CHECK_HINTS: keep the guard but THROW on the tagged arm, so a
# lying hint surfaces a clear error (dev aid; off by default, no perf cost).
(defn- emit-kw-lookup [subj-node m-expr k d-expr]
(def hinted (and subj-node (= :local (subj-node :op)) (= :struct (subj-node :hint))))
(def checked (and hinted (os/getenv "JOLT_CHECK_HINTS")))
(def m (if (symbol? m-expr) m-expr (jsym)))
(def wrap (fn [body] (if (symbol? m-expr) body ['let [m m-expr] body])))
(def err (when checked
['error (string "type hint violated on `" (subj-node :name) "`: ("
k " " (subj-node :name) ") — value carries :jolt/type "
"(a phm/sorted/transient/lazy-seq), not the plain "
"struct/record the ^:struct/^Record hint asserts")]))
(if (nil? d-expr)
(let [fast ['get m k]]
(wrap (cond
checked ['if ['get m :jolt/type] err fast]
hinted fast
['if ['get m :jolt/type] (tuple core-get m k) fast])))
(let [d (if (symbol? d-expr) d-expr (jsym))
v (jsym)
fast ['let [v ['get m k]] ['if ['nil? v] d v]]
body (cond
checked ['if ['get m :jolt/type] err fast]
hinted fast
['if ['get m :jolt/type] (tuple core-get m k d) fast])
body (if (symbol? d-expr) body ['let [d d-expr] body])]
(wrap body))))
(defn- emit-invoke [ctx node]
(def fnode (norm-node (node :fn)))
(def args (map |(emit ctx $) (vview (node :args))))
(def nop (native-op fnode (length args)))
(def argnodes (vview (node :args)))
(cond
nop (case nop
'++ ['+ (in args 0) 1]
@ -326,34 +371,21 @@
# records with direct field keys, nil, janet arrays, scalars) gets janet
# `get` semantics, which match core-get for keyword keys. Structs never
# store nil values (nil values force the phm rep), so present-but-nil
# can't be confused with missing on the fast arm.
# can't be confused with missing on the fast arm. A ^:struct/^Record hint on
# the subject skips the guard entirely (jolt-94n; see emit-kw-lookup).
(and (= :const (fnode :op)) (keyword? (fnode :val))
(>= 2 (length args) 1))
(let [k (fnode :val)
m-expr (in args 0)
# ^:struct hint (jolt-dad): the subject IR local asserts a plain
# struct/record map, so skip the :jolt/type guard entirely and emit a
# bare get (~20ns vs ~36ns guarded). Programmer-asserted, like a
# Clojure type hint — a lie just makes the raw get return the wrong
# thing, same contract as ^String. Only for a :local subject.
subj (norm-node (in (vview (node :args)) 0))
hinted (and (= :local (subj :op)) (= :struct (subj :hint)))
# when the subject is already a janet symbol (a local), read it
# directly — the guard + lookup both reference it, and locals are
# immutable reads, so no rebinding let is needed (saves a binding
# per lookup in exactly the hottest shape, (:k local))
m (if (symbol? m-expr) m-expr (jsym))
wrap (fn [body] (if (symbol? m-expr) body ['let [m m-expr] body]))]
(if (= 1 (length args))
(let [fast ['get m k]]
(wrap (if hinted fast ['if ['get m :jolt/type] (tuple core-get m k) fast])))
(let [d-expr (in args 1)
d (if (symbol? d-expr) d-expr (jsym))
v (jsym)
fast ['let [v ['get m k]] ['if ['nil? v] d v]]
body (if hinted fast ['if ['get m :jolt/type] (tuple core-get m k d) fast])
body (if (symbol? d-expr) body ['let [d d-expr] body])]
(wrap body))))
(emit-kw-lookup (norm-node (in argnodes 0)) (in args 0) (fnode :val)
(when (= 2 (length args)) (in args 1)))
# (get m :kw) / (get m :kw default) — same inlined keyword lookup as (:kw m),
# so an explicit get with a constant keyword gets the guard fast path and the
# ^:struct/^Record hint (jolt-94n). Only when the key is a constant keyword;
# a variable/number/string key falls through to core-get below.
(and (get-head? fnode) (>= (length args) 2) (<= (length args) 3)
(let [a1 (norm-node (in argnodes 1))] (and (= :const (a1 :op)) (keyword? (a1 :val)))))
(emit-kw-lookup (norm-node (in argnodes 0)) (in args 0)
((norm-node (in argnodes 1)) :val)
(when (= 3 (length args)) (in args 2)))
(direct-call? ctx fnode) (tuple (emit ctx fnode) ;args)
# Local callee (closure param, let-bound fn, defn self-name): inline the
# function check so the overwhelmingly-common function case is a direct

View file

@ -219,6 +219,18 @@
(not (let [m (cell :meta)] (and m (get m :redef)))))
(cell :inline-ir))))
# Is `name` (a bare type-name string, e.g. "Vec3") a defrecord/deftype? Both
# expand to define a ->Name positional constructor var (30-macros.clj), so its
# presence is the marker. Lets the analyzer resolve a ^Record type hint to the
# struct fast path: record instances are tables tagged :jolt/deftype (NOT
# :jolt/type), so a raw keyword get is correct for them (jolt-94n).
(defn h-record-type? [ctx name]
(def ctor (string "->" name))
(def cns (ctx-find-ns ctx (h-current-ns ctx)))
(if (or (and cns (ns-find cns ctor))
(ns-find (ctx-find-ns ctx "clojure.core") ctor))
true false))
(def- exports
{"form-sym?" h-sym? "form-sym-name" h-sym-name "form-sym-ns" h-sym-ns
"ref-put!" h-ref-put!
@ -233,7 +245,8 @@
"form-expand-1" h-expand-1 "resolve-global" h-resolve-global
"form-syntax-quote-lower" h-syntax-quote-lower
"host-intern!" h-intern!
"inline-enabled?" h-inline-enabled? "inline-ir" h-inline-ir})
"inline-enabled?" h-inline-enabled? "inline-ir" h-inline-ir
"record-type?" h-record-type?})
(defn install! [ctx]
(def ns (ctx-find-ns ctx "jolt.host"))

View file

@ -1,18 +1,21 @@
# ^:struct type hint (jolt-dad). A constant-keyword lookup on a local hinted
# ^:struct skips the :jolt/type guard and emits a bare get (~20ns vs ~36ns),
# the way Clojure type hints let the compiler specialize. The hint is a
# programmer assertion (a lie just makes the raw get return the wrong thing,
# same contract as ^String); these tests pin that an ACCURATE hint is
# correctness-preserving, that it drops the guard, and that it survives inlining.
# Type hints driving keyword-lookup specialization (jolt-94n). A local hinted
# ^:struct (a plain struct/record map) or ^Record (a defrecord/deftype) lets a
# constant-keyword lookup skip the :jolt/type guard and emit a bare get
# (~20ns vs ~36ns), the way Clojure type hints let the compiler specialize.
# Covers both (:k m) and (get m :k), hint propagation through inlining, the
# ^Record path, the JOLT_CHECK_HINTS dev aid, and that accurate hints preserve
# results. An inaccurate hint is a programmer error (like a wrong ^String): the
# raw get returns the wrong value, surfaced only under JOLT_CHECK_HINTS.
(import ../../src/jolt/api :as api)
(import ../../src/jolt/backend :as backend)
(import ../../src/jolt/reader :as reader)
(print "Struct hint (jolt-dad)...")
(print "Type hints (jolt-94n)...")
(os/setenv "JOLT_DIRECT_LINK" "1") # inline on, so hint-through-inline is exercised
(def ctx (api/init {:compile? true}))
(api/eval-string ctx "(ns sh)")
(api/eval-string ctx "(defrecord Vec3r [r g b])")
(each s ["(defn v3 [r g b] {:r r :g g :b b})"
"(defn dot [^:struct l ^:struct r] (+ (+ (* (:r l) (:r r)) (* (:g l) (:g r))) (* (:b l) (:b r))))"
"(defn sub [^:struct l ^:struct r] {:r (- (:r l) (:r r)) :g (- (:g l) (:g r)) :b (- (:b l) (:b r))})"
@ -23,22 +26,50 @@
(def code (string/format "%p" (backend/emit-ir ctx (backend/analyze-form ctx (reader/parse-string src)))))
(length (string/find-all ":jolt/type" code)))
# the guard is dropped for hinted subjects, kept for unhinted ones
# --- guard removal ----------------------------------------------------------
(assert (= 1 (guards "(fn [v] (:r v))")) "unhinted (:r v) keeps the guard")
(assert (= 0 (guards "(fn [^:struct v] (:r v))")) "hinted (:r v) drops the guard")
(assert (= 0 (guards "(fn [^:struct v] (:r v))")) "^:struct (:r v) drops the guard")
(assert (= 0 (guards "(fn [^Vec3r v] (:r v))")) "^Record (:r v) drops the guard")
(assert (= 1 (guards "(fn [^String v] (:r v))")) "^String (not a record) still guards")
(assert (= 0 (guards "(fn [^:struct v] (+ (+ (:r v) (:g v)) (:b v)))")) "all three hinted lookups bare")
(assert (= 0 (guards "(fn [^:struct v] (lensq v))")) "hint survives through an inlined call")
# (get m :k) gets the same treatment as (:k m)
(assert (= 1 (guards "(fn [m] (get m :k))")) "unhinted (get m :k) is guarded-inline")
(assert (= 0 (guards "(fn [^:struct m] (get m :k))")) "^:struct (get m :k) drops the guard")
(assert (= 0 (guards "(fn [^Vec3r m] (get m :k 0))")) "^Record (get m :k d) drops the guard")
# a variable (non-constant) key isn't a keyword literal, so the inline doesn't
# fire — it falls through to core-get, which still indexes correctly.
(assert (= 2 (api/eval-string ctx "((fn [m kk] (get m kk)) {:a 2} :a)")) "variable-key get via core-get")
(assert (= 10 (api/eval-string ctx "((fn [m i] (get m i)) [10 20] 0)")) "variable-key get indexes a vector")
# accurate hints are correctness-preserving (value identical to the guarded path)
# --- correctness (accurate hints preserve results) --------------------------
(assert (= 32 (api/eval-string ctx "(dot (v3 1 2 3) (v3 4 5 6))")) "hinted dot value")
(assert (= 14 (api/eval-string ctx "(lensq (v3 1 2 3))")) "hinted lensq (inline-flow) value")
(assert (= 7 (api/eval-string ctx "(:r (sub (v3 9 8 7) (v3 2 0 0)))")) "hinted sub field")
# a hinted value flowing through an inlined call still reads correctly
(api/eval-string ctx "(defn hit [^:struct ray ^:struct c] (lensq (sub (:origin ray) c)))")
(assert (= 48 (api/eval-string ctx "(hit {:origin (v3 5 5 5) :direction (v3 0 0 0)} (v3 1 1 1))"))
"hinted value through nested inline reads correctly")
# a missing key on a hinted struct still reads nil (struct miss), like a guarded get
(assert (= nil (api/eval-string ctx "((fn [^:struct m] (:absent m)) (v3 1 2 3))")) "hinted struct miss -> nil")
(assert (= 9 (api/eval-string ctx "((fn [^:struct m] (get m :absent 9)) (v3 1 2 3))")) "hinted get default")
# field access on a real record instance through a ^Record hint
(api/eval-string ctx "(defn vr-x [^Vec3r v] (:r v))")
(assert (= 5 (api/eval-string ctx "(vr-x (->Vec3r 5 6 7))")) "record field via ^Record hint")
# (get m :k) on assorted reps still matches core-get semantics (unhinted path)
(assert (= 2 (api/eval-string ctx "(get {:a 2} :a)")) "get struct present")
(assert (= nil (api/eval-string ctx "(get {:a 2} :z)")) "get struct miss")
(assert (= 1 (api/eval-string ctx "(get (hash-map :a 1 :x nil) :a)")) "get phm present")
(assert (= nil (api/eval-string ctx "(get (hash-map :a 1 :x nil) :x)")) "get phm nil value")
(assert (= 7 (api/eval-string ctx "(get (sorted-map :a 7) :a)")) "get sorted present")
(print "Struct hint passed!")
# --- checked mode: a lying hint throws (separate ctx with the flag on) -------
(os/setenv "JOLT_CHECK_HINTS" "1")
(def cctx (api/init {:compile? true}))
(api/eval-string cctx "(ns ck)")
(api/eval-string cctx "(defn rd [^:struct m] (:a m))")
(assert (= 1 (api/eval-string cctx "(rd {:a 1 :b 2})")) "checked mode: accurate hint still works")
(let [r (protect (api/eval-string cctx "(rd (hash-map :a 1 :x nil))"))]
(assert (not (r 0)) "checked mode: lying ^:struct hint throws")
(assert (string/find "type hint violated" (string (r 1))) "checked-mode error is meaningful"))
(os/setenv "JOLT_CHECK_HINTS" nil)
(print "Type hints passed!")