Skip to content

Number generated names per compilation unit - #568

Open
MrDennisV wants to merge 1 commit into
Chia-Network:mainfrom
MrDennisV:gensym-per-unit
Open

MrDennisV wants to merge 1 commit into
Chia-Network:mainfrom
MrDennisV:gensym-per-unit

Conversation

@MrDennisV

@MrDennisV MrDennisV commented Sep 16, 2026 •

Copy link
Copy Markdown

What goes wrong

Importing a program compiles it as a unit of its own, and the importer embeds the hash of what comes out as program_hash. That hash is not a function of the imported file alone: it also depends on what the importer compiled before the import.

resources/tests/module/programs/gensym-order.clsp (a defun with three repeated subexpressions) imported on its own:

(import qualified programs.gensym-order as M)
(export () M.program_hash)

gives 0x2a893a5d…. Imported after a one-let program:

(import qualified programs.gensym-order-prefix as P)
(import qualified programs.gensym-order as M)
(export () M.program_hash)

gives 0xc032511d…. Both are honest — each is the tree hash of the bytes that import actually produced — and both differ from a top-level compile of the same file. The programs are the same size and differ in the environment positions of two hoisted bindings.

This is what a game built on chia-gaming runs into: each validator names its successor by program_hash, the factory registers the copies it imported, and when the two imports were numbered differently the session stops at that transition with "validator transition returned unregistered program hash". Which links break moves around with unrelated edits anywhere in the package.

Why

gensym numbers the names the compiler makes up (letbinding_$_N, lambda_$_N, cse_$_N, renamed variables) from ARGNAME_CTR, a lazy_static AtomicUsize shared by the whole process and never reset. Preprocessor::import_program compiles the imported file with a fresh allocator, symbol table and context, but the counter carries over, so the nested unit's names start wherever the importer and its earlier imports left it — later imports never matter, earlier ones always do.

Names then decide layout in one place: the CSE pass groups the subexpressions it finds in a BTreeMap keyed by the hash of the renamed expression ("Group them by hash since we've renamed variables"), so the order in which it hoists them — and the order of the resulting bindings in the environment — follows the digits in p_$_437 versus p_$_471. Two or more repeated subexpressions in one function are enough.

Change

  • gensym.rs: the counter is a thread_local! Cell<usize>, so compiles on different threads no longer interleave, and GensymUnit (crate-private) restarts it for a compilation unit and restores the enclosing count when dropped — the same shape as NewStyleIntConversion in clvm.rs.
  • compiler.rs: compile_pre_forms holds a GensymUnit. Every unit passes through it — a top-level file, a program compiled through the library, an import — so each is numbered from zero, and a file compiles to the same bytes whether it is imported, compiled on its own, or compiled again later in the same process.
  • A test that takes the fixture's own hash, then imports it repeatedly, alone and after a prefix, and requires every import to yield that hash. It fails on main (the two hashes above) and passes here. It sets optimize the way the command line does for cl23; the module test harness left it off, which is why nothing in the existing suite noticed — without the Strategy23 optimizer there is no CSE and the names never matter.

ARGNAME_CTR was pub but nothing outside gensym.rs used it; it is private now.

Why it surfaced now

Until #531 the on-disk module cache from #345 returned the first compile's bytes for every later import of a module with the same fingerprint, which would have hidden this whenever the cache was warm; 0.5.0 shipped four days after the cache came out. Inferred from the history rather than measured on a pre-#531 build.

What changes for existing builds

A file that imports programs compiles to different bytes once after this, because it no longer inherits the count its imports consumed. After that, every context agrees: a program's bytes depend on its own source alone, and its program_hash is the same wherever it is imported from and equal to its own compile.

Full cargo test --lib, cargo fmt and cargo clippy pass.


Note

Medium Risk
Changes compiled output for multi-import builds and affects program_hash identity used by module/gaming flows, though behavior becomes correct and deterministic.

Overview
Fixes non-deterministic program_hash for imported modules when earlier compiles in the same process had already advanced the global gensym counter. Compiler-generated names (letbinding_$_N, CSE hoists, etc.) now restart at zero per compilation unit via thread-local counting and GensymUnit in compile_pre_forms, so an imported file’s bytes match a standalone compile regardless of import order or prior edits in the importer.

Existing builds that import programs will get different hex once (importers no longer inherit consumed counter state); after that, hashes are stable and source-only. Adds CL23 optimizer fixtures and a regression test that compares standalone vs repeated imports (alone and after a prefix import).

Reviewed by Cursor Bugbot for commit 061951b. Bugbot is set up for automated code reviews on this repo. Configure here.

An imported program is compiled as a unit of its own, and the importer
embeds the hash of what comes out as program_hash. The names the compiler
makes up along the way (letbinding_$_N, lambda_$_N, cse_$_N, renamed
variables) were numbered from a counter shared by the whole process and
never reset, so a compile started wherever earlier compiles had left it.
The CSE pass orders the bindings it hoists by the hash of the renamed
expressions, so a file with two or more repeated subexpressions compiled
to different bytes depending on what preceded it: its program_hash as
seen from one importer did not match the hash seen from another, or the
hash of the file compiled on its own.

The counter now lives on the thread, and compile_pre_forms, where every
unit passes, top-level, library or imported, numbers it from zero and
restores the enclosing count when done, so what a file compiles to
depends on its own source alone. A file that imports programs compiles
differently once as a result, since it no longer inherits the count its
imports consumed.

The new test turns the optimizer on the way the command line does for
cl23; the module test harness left it off, so nothing in the suite had
CSE and the numbering never showed.

This branch has not been deployed

No deployments
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.

1 participant