Skip to content

fix(reader): set literals read as sets, not as (hash-set …) - #736

Open
nnunley wants to merge 4 commits into
nooga:mainfrom
nnunley:set-literals-read-as-data
Open

fix(reader): set literals read as sets, not as (hash-set …)#736
nnunley wants to merge 4 commits into
nooga:mainfrom
nnunley:set-literals-read-as-data

Conversation

@nnunley

@nnunley nnunley commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

(read-string "#{1}") returned the form (hash-set 1) where Clojure returns the set #{1}. Maps and vectors already read as data; sets were the only collection literal that did not.

Stacked on #734.

Before

(read-string "#{1 2}")        ;=> (hash-set 2 1)
(set? (read-string "#{1}"))   ;=> false
(read-string "{:a 1}")        ;=> {:a 1}        ; already data
(read-string "[1 2]")         ;=> [1 2]         ; already data

Genuine reader macros were never the issue — 'sym, @x and #'foo expand to forms in Clojure too. #{} was the outlier.

Change

readSet builds the set value instead of consing hash-set onto a list. Evaluation is unchanged: compileForm's vm.SetType case already emitted the same hash-set invocation and compiled each element, so #{x (f y)} still evaluates its elements.

What that surfaced

Three sites had assumed a set literal always arrives as a list, because before this it always did.

ir/build.lgbuild-form threw on the first quoted set it saw:

ERROR in test: build-form: unrecognized form #{entry}

from test/gogen_nth_non_int_test.lg, whose trigger form contains visited #{entry}. Adds build-set, modelled on build-vector, plus the set? cases in captures-of and free-vars so a value captured or free only inside a set literal is still walked — the same reason the map cases exist.

ir/lower_go.lg — a set reaching :const had no Go emitter, so the lowered tree stopped compiling:

pkg/rt/core_go_lowered/ir/build/build.go:1872:6: declared and not used: name_sym
pkg/rt/core_go_lowered/ir/build/build.go:1999:1: missing return

That is BuildFn, because build-fn* uses #{} as a reduce init (build.lg:1533). Adds boxed-set-exprvm.NewSet, alongside the existing vector, list and map emitters.

core.lg needed nothing: the case macro already tested (or (set? test-val) set-literal?), so it accepted both shapes.

Tests

test/set_literal_reader_test.lg, 15 assertions across three deftests, pinning the data/eval split in both directions:

  • read-string returns a set, is set?, equals #{1 2}, and is not a list; #{} reads as an empty set.
  • Maps and vectors still read as data; 'sym and @x still expand to forms.
  • Unquoted set literals still evaluate their elements (#{1 2 x} with x bound, #{(+ 1 1)}).
  • Quoted set literals hold unevaluated elements — '#{a b} equals #{'a 'b}, and '#{entry} contains the symbol, which is the IR case that was previously unreachable.

Validation

  • make generate + make check-generated clean; lowered tree compiles and dispatches natively under -tags gogen_ir.
  • go test ./test/ ./pkg/... green.
  • Bundle and lowered tree regenerated.

@mparrett
mparrett self-requested a review August 13, 2026 00:10

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The representation change exposes three additional traversal gaps:

  1. Deftype/defrecord field references inside sets no longer compile. -dt-rewrite in pkg/rt/core/core.lg treats non-sequential forms as atomic. Sets previously arrived as (hash-set …) lists, so their elements were recursively rewritten. On this branch:
(defprotocol SetFieldP (field-set [this]))
(deftype SetFieldBox [x] SetFieldP (field-set [this] #{x}))
(field-set (->SetFieldBox 7))

fails with Can't resolve x in this context; the base branch returns #{7}. Please add set traversal there and in the native dt-rewrite-field equivalent.

  1. Native IR lowering does not macroexpand set elements. expand-all in pkg/rt/core/ir/passes/pipeline.lg descends into vectors and maps but not sets. A gogen fixture with (defn run [] #{(when true 1)}) returns #{1} under bytecode but #{(if true (do 1) nil)} under native dispatch. The gogen trampoline passes on main and fails on this PR. expand-all needs a set branch before build-set receives the elements.

  2. Syntax-quoted sets bypass unquote and symbol processing. syntaxQuote in pkg/compiler/reader.go handles vectors, maps, and lists, but the new SetType falls into the default quote branch. (let [x 1] `#{~x}) now yields #{(unquote x)} instead of #{1}; gensym and namespace qualification inside syntax-quoted sets are likewise skipped.

The regular go test -count=1 ./test/ ./pkg/... suite passes and make check-generated is clean, so these need focused regression coverage.

@nnunley

nnunley commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Performance: branch vs main

The ratchet's baseline (docs/perf/baseline.json) was captured on go1.26.3 and the current toolchain is go1.26.5, so make bench-ratchet reports regressions on main itself. A control run on unmodified main (af4b7f4) reproduces them with byte-identical allocation counts, which scopes them out of this change:

benchmark control = main this branch
pkg/compiler.BenchmarkInitFromLGB +42.6% +33.2%
pkg/ir.BenchmarkIRCompile [bytecode] +9.9% +10.4%
pkg/ir.BenchmarkIRCompile [gogen_ir] −13.7% −23.2%

Branch measured against main on the same toolchain and machine, anchor-normalised:

benchmark main × branch × Δ vs main wall
pkg/compiler.BenchmarkInitFromLGB 1 307 085 1 220 929 −6.6% 1.36 ms
pkg/ir.BenchmarkIRCompile [bytecode] 14 537 718 14 607 273 +0.5% 16.2 ms
pkg/ir.BenchmarkIRCompile [gogen_ir] 10 624 979 9 461 319 −11.0% 10.5 ms

Allocation counts are identical between control and branch — 7028 → 26118, 100159 → 114256, 111864 → 151751 — so none of the allocation growth in the ratchet output originates here.

Interleaved A/B

scripts/ab_repeat.py --n 7, profile pr-fast, order BH HB BH HB BH HB BH, 53 benchmarks on Apple M3 / go1.26.5:

would-gate (any comparable family's median > budget):
  budget  6%: clean
  budget  8%: clean
  budget 10%: clean

Every family median lands within ±0.8% (largest: VectorCreation/ArrayVector/100 +0.75%, VectorConj/ArrayVector/1000 +0.71%, FuncInvoke/Closure +0.22%). The harness classifies the run as a no-op.

Limits of the above

pr-fast covers pkg/vm under -tags gogen_ir only. It does not include BenchmarkInitFromLGB or BenchmarkIRCompile, so the two rows above are single-run measurements. Both point in the improvement direction, and neither is confirmed by repetition; I would not claim them as results.

Separately, docs/perf/baseline.json needs re-derivation against the current toolchain before the ratchet can gate anything — related to #663.

@nnunley

nnunley commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in 96647a94, but the fix is wider than reviewed and I want the shape re-reviewed rather than just the branches.

Your three items — all three reproduced

item site confirmed
1 -dt-rewrite (core.lg) yes
2 expand-all (pipeline.lg) yes
3 syntaxQuote (reader.go) yes — `#{~x} gave #{(unquote x)}

Two of them predate this PR

Probing each site with map/set/vector/list literals turned up more than set gaps:

literal in a deftype method main this branch, before the fix
[x] [7] [7]
{:k x} COMPILE-ERROR COMPILE-ERROR
#{x} #{7} COMPILE-ERROR

{:k field} in a deftype/defrecord method has never compiled — Can't resolve x in this context on main today. And expand-all returns #{(when true 1)} unexpanded on main as well; it was simply unreachable while sets read as (hash-set ..) lists and took the seq arm.

So item 1's map half and item 2 are pre-existing defects this PR surfaces, not causes. Item 3 is genuinely new.

A fourth site

dt-rewrite-field in pipeline.lg — the native-lowering twin you referred to as "the native dt-rewrite-field equivalent". Its docstring says it mirrors core.lg's -dt-rewrite; it mirrored the defect too, with the same vector?/seq?-only dispatch.

Why not just add set? branches

All four sites enumerate concrete types and treat anything unmatched as an atom. Adding set? fixes today's symptom and leaves the mechanism that produced it — the map hole proves it has already failed silently once, for as long as the code has existed.

Each site now dispatches on the collection lattice:

(cond
  (symbol? form)     ...
  (map? form)        ... ; associative arms FIRST
  (set? form)        ...
  (vector? form)     ...
  (not (seq? form))  (if (coll? form) (throw ...) form)   ; loud, not silent
  ...)

Two details worth checking in review:

  • Associative arms must precede vector?, because a map entry is a vector ((vector? (first (seq {:a 1}))) => true). Descending into an entry as a 2-vector rewrites the key as though it were a value. The new tests cover map KEYS specifically.
  • Unknown coll? throws. Same rule as the ops catalog in Consolidate IR op definitions into single-source catalog (multimethods/macros) #268 — a missing facet should be a loud failure, not an invisible nil.

sequential? would have been the wrong guard here, incidentally: it is false for sets, so a sequential?-keyed traversal still skips #{…}.

Regression coverage

Four falsifiers, each failing on main:

  • defrecord_field_scope_test.lg — map values, map keys, set elements, collections nested in a map
  • special_forms_test.lg — unquote / unquote-splicing / nesting inside a syntax-quoted set
  • expand_all_collections_test.lg — macroexpansion inside every collection kind

special_forms_test.lg on main: 25 pass / 3 fail. On this branch: 28 pass / 0 fail.

Validation

  • make check-generated clean — bundle, lowered tree, both registrars in lockstep
  • go test ./test/ ./pkg/... — green, 0 failures
  • make gogen-diffTestGogenAOTDiff PASS (the native/bytecode differential your item 2 cited)
  • make ir-stress-gate — 2503/2514 lower natively, 11 failures all within the known baseline bucket

Also corrected two comments in core.lg and pipeline.lg still asserting that set literals read as (hash-set ..) forms.

Kept as a separate commit from the reader change so the representation change and the traversal repair review independently.

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three items from the first round reproduce as fixed. I built 96647a94 and its stack base ca44b6d7 and ran ~40 probes across both:

probe base ca44b6d7 branch 96647a94
#{x} in a deftype method #{7} #{7}
{:k x} in a deftype method Can't resolve x {:k 7}
`#{~x} (hash-set 1) #{1}

The claim that the map half of item 1 predates this PR checks out: the base binary errors on {:k x} too. go test -count=1 ./test/ ./pkg/... is green, make check-generated is clean, make gogen-diff passes, and every probe returns identical output under -tags gogen_ir.

On the wider reshape: the expand-all set arm closes a divergence where bytecode expanded a set element and native lowering did not, which is worth having on its own. Three things need another pass — a new regression in the reader, and two in the lattice dispatch.

Duplicate element forms now evaluate once

reader.go:819 builds a set of forms, so two =-equal element forms collapse before compilation and only one of them is ever evaluated:

#{(gensym) (gensym)}           base: #{G__1 G__2}   branch: #{G__1}
#{(swap! c inc) (swap! c inc)} base: counter 2      branch: counter 1

Clojure's reader rejects this outright with Duplicate key, so silently evaluating one of the two is a behavior neither old let-go nor Clojure has. No test covers it. Either throw the way Clojure does, or carry the elements as a slice through the read and build the set at compile time.

The coll? guard cannot fire

core.lg:2187, pipeline.lg:240, and pipeline.lg:423 all end the dispatch with (if (coll? form) (throw ...) form). But coll? is (or (list? x) (vector? x) (map? x) (set? x) (seq? x)) (core.lg:2036), and every one of those is matched by an earlier arm, since list? implies seq?. Nothing reaches the throw. I checked the escape hatch too: a record is coll? false, so it still passes through as an atom.

So the stated rationale — the next collection type added to the reader fails loudly here instead of silently skipping rewrite — does not hold, because coll? would not know that type either. The native isColl at lang.go:2284 tests the vm.Collection interface and would catch records and queues, but core.lg's defn shadows it. For the loud failure, the fallthrough has to test something other than coll?: an allowlist of atom predicates, or the native check.

Dead FormSource call

reader.go:820 still calls vm.FormSource.Set(result, ...), but formSourceMap.Set stores only *List and *Cons (source.go:296), so it is a no-op for *PersistentSet. Nothing degrades: error spans inside a set literal still point at the right line, and the frame label improved to "compiling set element". The call and the startLine/startCol computation above it are now dead.

Before this can merge

  • CI has never run on this PR. statusCheckRollup is empty and there are no workflow runs for 96647a94 or the branch, while #734 from the same fork is green across 19 checks.
  • mergeStateStatus is DIRTY. The merge is clean locally because the repo's merge driver regenerates core_compiled.lgb and generated.sums; GitHub has no driver, so it needs a rebase onto 3d8c9d93.
  • The base is main, not warn-on-core-shadow-fix, so the PR carries #734's three commits and shows 21 files / +395 where the net change is 10 files / +255. Retarget it, or land #734 first; otherwise the squash message describes the wrong change.

@mparrett

Copy link
Copy Markdown
Collaborator

To clarify, since the above reads flatter than I meant it:

  • The dedup regression is the blocker. It needs a fix or a decision that let-go diverges from Clojure here on purpose.
  • The coll? guard I'd either make fire or remove along with the three comments claiming it does. A guard that can't trigger is worse than no guard, because the next person to add a collection type will trust it. Not blocking.
  • The FormSource call is a delete.

Nothing else in the reshape needs changing from my side.

(read-string "#{1}") returned the form (hash-set 1) — a constructor call —
where Clojure returns the set #{1}. Maps and vectors already read as data;
sets were the only collection literal that did not, so (set? (read-string
"#{1}")) was false and anything reading EDN got a list back.

readSet now builds the set value. Evaluation is unchanged: compileForm's
vm.SetType case already emitted the same hash-set invocation and compiled each
element, so #{x (f y)} still evaluates its elements.

Three downstream sites had assumed a set literal always arrives as a list,
because before this it always did:

- ir/build.lg — build-form threw `unrecognized form #{entry}` on the first
  quoted set it saw. Adds build-set (modelled on build-vector) plus the set
  cases in captures-of and free-vars, so a value captured or free only inside a
  set literal is still walked.
- ir/lower_go.lg — a set reaching :const had no Go emitter, and the lowered
  tree failed to compile with unused locals and a missing return in BuildFn
  (build-fn* uses #{} as a reduce init). Adds boxed-set-expr → vm.NewSet.

core.lg's `case` macro needed nothing: it already tested (or (set? test-val)
set-literal?), accepting both shapes.

Verified: make generate + make check-generated clean, the lowered tree compiles
and dispatches natively, ./test and ./pkg/... pass, and
test/set_literal_reader_test.lg pins the data/eval split in both directions —
read-string returns a set, quoted set literals hold unevaluated elements,
unquoted ones still evaluate, and ' @ still expand to forms.
… type

Set literals reading as data exposed traversal gaps at four sites that
dispatched on vector?/seq? and returned every other collection unchanged.
Two of the gaps predate this PR.

  core.lg -dt-rewrite            map literals have NEVER been rewritten:
                                 {:k field} in a deftype/defrecord method
                                 fails on main with "Can't resolve field in
                                 this context". Sets were rewritten only by
                                 accident, via the seq arm, while they still
                                 read as (hash-set ..) lists.
  pipeline.lg dt-rewrite-field   the native-lowering twin; its docstring says
                                 it mirrors -dt-rewrite, and it mirrored the
                                 defect too.
  pipeline.lg expand-all         set elements were never macroexpanded. Also
                                 pre-existing and previously unreachable, so
                                 bytecode and native lowering disagreed on
                                 #{(when true 1)}.
  reader.go syntaxQuote          had VectorType/MapType/ListType but no
                                 SetType, so `#{~x} produced #{(unquote x)};
                                 unquote, gensym and ns-qualification were all
                                 skipped. Adds flattenSet alongside flattenMap.

Each site now keys off the collection lattice: associative arms BEFORE
vector? (a map entry IS a vector, so descending into it as a 2-vector
rewrites the key as a value), and an unrecognised coll? throws instead of
falling through as an atom. That makes the next collection type added to the
reader fail loudly here rather than silently skipping traversal.

Also corrects two comments in core.lg and pipeline.lg that still asserted
set literals read as (hash-set ..) forms.

Regression coverage, each failing on main:
  defrecord_field_scope_test.lg   map values, map KEYS, set elements, and
                                  collections nested inside a map
  special_forms_test.lg           unquote / unquote-splicing / nesting inside
                                  a syntax-quoted set
  expand_all_collections_test.lg  macroexpansion inside every collection kind

Validation: check-generated clean; go test ./test/ ./pkg/... green;
make gogen-diff (TestGogenAOTDiff) green; make ir-stress-gate 2503/2514
native, 11 failures within the known baseline bucket.
Set literals are sets of forms, so constructing the set in one step silently
collapsed equal forms before compilation. Reject duplicates during reading,
matching Clojure and preserving the old guarantee that duplicate side-effecting
forms are never silently evaluated once. Remove the no-op set FormSource write
and the unreachable coll? guards whose comments promised a loud fallback they
could not provide.
@nnunley
nnunley force-pushed the set-literals-read-as-data branch from 96647a9 to f011633 Compare August 15, 2026 00:12
@nnunley

nnunley commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Second-round findings are fixed at f01163374123. Duplicate equal set element forms now fail during reading with Duplicate key: rather than collapsing before compilation; regression cases cover scalar, vector, (gensym), and side-effecting (swap! c inc) forms. The no-op FormSource write/start coordinates are removed, as are the three unreachable coll? guards and their misleading loud-fallback claims. Final pre-push suites, IR stress, generated checks, and native/bytecode differential pass.

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original findings are fixed, but this new set traversal introduces a scoped native correctness regression.

expand-all collapses distinct source forms after macroexpansion

The new set arm in pkg/rt/core/ir/passes/pipeline.lg rebuilds the expanded elements with (into #{} ...). Two distinct source forms are therefore deduplicated if macroexpansion makes them equal, before either form is evaluated.

Reproducer:

(defn run []
  (let [c (atom 0)]
    #{(when true (swap! c inc))
      (if true (do (swap! c inc)) nil)}
    @c))

when expands to the second if form. Rebuilding the form collection as a set drops one element, so only one swap! remains in native lowering.

Verified with the repository's gogen trampoline:

revision bytecode native result
base b32acf6 2 2 PASS
PR head f011633 2 1 FAIL

This is therefore introduced by this PR, not a pre-existing map/set traversal defect. The simple #{(when true 1)} test passes because there is no second form to collide with after expansion.

Please preserve the expanded element sequence until runtime construction—e.g. produce a (hash-set ...) call or use another non-deduplicating intermediate representation—instead of rebuilding macroexpanded forms with into #{}. A regression test should assert bytecode/native parity and that both side effects occur.

@nnunley

nnunley commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Remediation published at 48188b5158853a2265d701bca3b79c86af11465e.

readSet now routes elements through appendNonVoid, so #?@ splices expand element-by-element, duplicate detection sees expanded forms, and one-shot splicing state is consumed/reset at the set boundary. Regression coverage includes direct splicing, enclosing-vector boundaries, splice-created duplicates, and unmatched-condition state leakage.

Evidence: focused reader tests, compiler suite (61), short suite (1,734), compatibility suite (243), generation/check-generated, gogen-diff, IR stress, and independent re-review passed. The published head now has no failing GitHub checks (14 passed; non-applicable checks skipped).

@mparrett

Copy link
Copy Markdown
Collaborator

Rechecked the published head 48188b5158853a2265d701bca3b79c86af11465e.

That commit correctly addresses reader-conditional splicing inside set literals, but it does not change the blocking expand-all set arm in pipeline.lg. After rebuilding this exact head, the previously posted collision fixture still reports:

bytecode=2 native=1
gogen-trampoline: FAIL

The existing changes-requested finding therefore remains open: macroexpanding set elements into (into #{} ...) deduplicates distinct source forms that expand identically, dropping an evaluation on the native path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants