Skip to content

fix(hy-emit): N5 zero-value maps allocate at declaration so the first write works (fixes #645) - #662

Merged
limitlessprojects merged 1 commit into
limitlessprojects:mainfrom
Morphexe:fix/645-n5-zero-map-first-write
Oct 18, 2026
Merged

limitlessprojects merged 1 commit into
limitlessprojects:mainfrom
Morphexe:fix/645-n5-zero-map-first-write

Conversation

@Morphexe

Copy link
Copy Markdown
Contributor

Fixes #645.

Problem

SPEC-LANGUAGE N5 (and T8): var m map[k]v; m[k] = v works — first write allocates — where Go panics on a nil map. N3 gives maps an empty zero value and structs field-wise zero values, so a map field of a zero-valued struct is covered too. The emitter lowered every zero map Go-exact, so the issue's program passed hyb check, built, and panicked at its first write:

panic: assignment to entry in nil map

Fix

hy-emit now allocates a zero map where it is declared (new zeroinit.rs):

Hybrid emitted Go
var m map[string]int var m map[string]int = map[string]int{}
var b bag (bag has a map field) var b bag = bag{counts: map[string]int{}}
bag{label: "x"} / bag{} the unkeyed map fields are added: bag{label: "x", counts: map[string]int{}}
nested struct fields, type tally = map[...] aliases resolved recursively: shelf{top: bag{counts: …}, tags: tally{}}
new(bag) &bag{counts: map[string]int{}}

Why declaration-site allocation and not a write-site guard: a guard at x[k] = v needs the place's static type (the emitter has none for arbitrary places) and evaluates the place twice. Per the spec the two are the same value: a Hybrid map has no nil (N1) and its zero value is the empty map (N3). Fields the literal keys itself are never touched, and types whose Go zero value already matches Hybrid's (int, plain structs, …) emit exactly as before.

Kept deliberately minimal while D11 (keep N5 vs Go-exact) is open — this follows the spec as written, and the corpus verdict note says a D11 reversal amends it. Not covered by this change: generic structs (box[int]{} — field types need the folded type arguments substituted), arrays of map-carrying structs, make([]bag, n) elements, and named fn results. Those keep Go's zero value, as today.

No checker change.

Tests

  • Corpus (accept side): tests/checker-corpus/n5-zero-map-first-write.hy, registered additively per the corpus conventions — EXPECTED-VERDICT: ACCEPT, a VERDICTS.md registration section, README census, parse_all count 79 → 80. Corpus is now 80 programs: ACCEPT 22 · ACCEPT-MANAGED 2 · REJECT 56. It covers a var zero map, a zero-valued struct field, a partially keyed literal, a nested struct, an aliased map type, new(T), and a compound first write (t["z"] += 7).
  • Byte-exact output: the program's stdout is in its header as // EXPECTED-OUTPUT: lines (the corpus-35 marker). The differential loop only checks build + exit 0, so compiler/crates/hyb/tests/spec_n5_zero_maps.rs builds it with hyb build and diffs stdout byte-exact.
  • Emission shapes: compiler/crates/hy-emit/tests/n5_zero_map_lowering.rs (var / alias / nested / keyed-field-untouched / new / plain structs unchanged / generic structs left alone).

On main @ 0ca08c2 the corpus program builds and panics at the first write (assignment to entry in nil map, exit 2). With this branch it prints the expected output.

Local run of everything .github/workflows/ci.yml runs (Go 1.26.9, Rust 1.99.0):

  • cargo test --workspace: 1148 passed, 0 failed (main: 1143)
  • tools/corpus_harness.py: 80/80 verdict agreement, tripwire not fired (2.5%)
  • tools/differential_loop.py: FULL AGREEMENT, accept 24/24, reject 56/56
  • tools/run_examples.sh: all 10 examples pass
  • managed gate PASS, check-versions.sh CONSISTENT, row-13 gomod sample (build/vet/test/run + drift detection) OK, Go runtime hygiene OK, every Python suite in the quick job OK, no conflict markers
  • the emitted Go for the corpus program is gofmt-clean and go vet-clean

… write works (fixes limitlessprojects#645)

SPEC-LANGUAGE N5 / T8: `var m map[k]v; m[k] = v` works (first write
allocates), where Go panics on a nil map; N3 extends the empty-map zero
value field-wise into structs. The emitter lowered every zero map
Go-exact, so an accepted program built and then died with
`assignment to entry in nil map`.

The zero map is now allocated where it is declared (zeroinit.rs):
`var` locals with no initializer, the unkeyed map fields of struct
literals (recursively through nested struct fields), and `new(T)`, with
user type aliases resolved. Generic structs keep Go's zero value (their
field types need the literal's type arguments substituted).

Corpus: n5-zero-map-first-write.hy registered additively as ACCEPT
(80 programs: ACCEPT 22 / ACCEPT-MANAGED 2 / REJECT 56), its stdout
pinned byte-exact by hyb/tests/spec_n5_zero_maps.rs; emission shapes
pinned by hy-emit/tests/n5_zero_map_lowering.rs. D11 stays open; this
follows the spec as written.
@limitlessprojects
limitlessprojects merged commit 74af832 into limitlessprojects:main Oct 18, 2026
2 checks passed
@limitlessprojects

Copy link
Copy Markdown
Owner

Landed as maintainer integration 74af832 (merged onto current main). Thank you, @Morphexe — this is the landed take for #645; the write-site-guard alternative (#663) is being credit-closed with the analysis.

What was adapted in the merge (content preserved)

Verification on the merged tree (main @ 74af832, go1.26.9 / rust 1.99.0)

Scenario main before merged tree
Issue #645's exact program (var m map[string]int; m["a"] = 1, var b bag; b.counts["k"] = 2) check OK → builds → panic: assignment to entry in nil map ACCEPT → builds → prints 1 / 1
alias tally, nested shelf{top: bag{…}, tags: tally{}}, new(bag), keyed/partially-keyed/full literals Go-exact nil all allocate at declaration; keyed map field never doubled (bag{counts: map[string]int{"z": 8}} verbatim)
types whose Go zero value already matches (var n int, plain{}) — emit exactly as before (asserted by the PR's own tests)
  • Battery: cargo 1281/0 (+4 from this PR: 3 n5_zero_map_lowering.rs emission pins + the byte-exact spec_n5_zero_maps.rs corpus runner), corpus 81/81, census 7/7, differential FULL 24+57 (accept side grew 23→24 with the fixture, and now byte-asserts its stdout). CI green on the PR head f68595a (quick+compiler).
  • Deliberate scope kept as documented in zeroinit.rs: generic structs keep Go's zero value (their field types need the literal's type arguments substituted), and the emit never produces invalid Go for them.

Side finding (pre-existing, NOT a #662 regression — verified identical on pre-merge main)

Generic struct emission itself is broken on main today: struct box[t] { v t } emits type box [t]struct { (Go parses it as an array type), so ANY generic-struct program fails go build (box is not a generic type). Accepted-but-unbuildable, fail-closed at build. Filed observation for the supervisor; separate from N5.

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.

[soundness] N5 not implemented: a zero-value map write (var m map[string]int; m["a"] = 1) passes hyb check, builds, and panics at runtime

2 participants