Skip to content

fix(codegen): compute numeric provenance after the Ptr<Shape> receiver proofs it depends on - #10929

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:l14-order-clean
Closed

proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:l14-order-clean

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

What this is

A bug fix, not a performance change. On the program it targets it takes a loop from 32 to 16
instructions per iteration; on a real 1,201-line TypeScript compile it changes nothing — measured,
byte-identical binary. Both numbers are below.

Refs #10777.

The defect

collectors/hir_facts.rs computed number_by_construction_locals before
collect_shape_proven_ptr_locals. For h = h + o.a, admitting h requires judging o.a
Number-producing, and expr_numeric_by_construction's PropertyGet arm is gated on the receiver being
a tracked member. So the function-scope entry point passed:

let empty_members: HashSet<u32> = HashSet::new();
let empty_fields: HashSet<String> = HashSet::new();

hardcoded (ptr_shape_numeric.rs). That arm could never fire for a function-scope walk — for any
spelling, construction or field type — so an accumulator was never admitted however completely its
receiver's shape was proven.

This is why #10777's landed read-side fix (72206caa71) changed nothing on its target: the read was
vouched correctly and the accumulator was not. A probe on the + routing decision
(expr/binary.rs, both_numeric) on a fixture whose opt report says
Ptr<Shape> 1 selected / 1 CONSUMED:

off: left=LocalGet(num=false canon=false)  right=PropertyGet(num=true canon=true)  both_numeric=false => GUARDED
on:  left=LocalGet(num=true  canon=false)  right=PropertyGet(num=true canon=true)  both_numeric=true  => INLINE_FADD

It also explains the old o.a * 1 result: that spelling gives the accumulator a Binary write, so its
own fixed point closes. The multiply was never normalising anything.

The change

Move the computation after the receiver proofs and thread them in. Two parameters that were
hardcoded empty become real. No new admission arm, no new provenance class, no new fact.

127 insertions, 16 deletions, five files.

One soundness decision: the shape inputs are the intersection of the proven receivers' numeric
field sets. The arm consumes one set and does not re-check which receiver a property belongs to, so the
set must be numeric on every admitted receiver. A union would be a wrong answer, not a weaker one:
a numeric on C but not on D would license a bare fadd on D.a. Exact in the single-receiver
case; an under-approximation when receivers disagree.

Default OFF behind PERRY_L14_NBC_ORDER=1, keyed into the object cache. With it off the inputs are
empty and the fixpoint computes exactly what it computed before — the reorder is pure.

Evidence

Every count below was predicted and committed before the build existed. Instrument asserted present in
the compiler before any measurement.

off on
route at the + GUARDED INLINE_FADD
cold-arm js_dynamic_string_or_number_add in loop body 1 0
run() code, off arm vs pre-change build identical (knob is free)
output vs node identical identical
instructions / iteration 32.00 16.00

node on the same fixture: 8.79–9.77 across runs, so 1.7–1.8×. Not parity, and not claimed as such.

Blast radius — the number to weigh this by:

workload + sites guarded off → on route flips emitted code
tsc 5.8.2 transpileModule, 1,201-line input 992 952 → 952 0 byte-identical
zod 3.23.8 schema + safeParse 1 0 → 0 0 0 functions differ*

*zod's binaries differ between arms, but the same arm built twice differs by the same 38 bytes — a
string-pool permutation in perry's own output (commented on #10590), not this change. Symbol-level
diff: 0 functions change.

Why zero on tsc: the fix only helps an accumulator whose writes read a field of a receiver with a
Ptr<Shape> proof. tsc's + sites read from any-typed AST nodes, parameters and receivers the
compiler cannot name — exactly where Ptr<Shape> fails. The fixture shows the effect because it was
built so the proof would fire.

Differential suite — 893-program gap suite on exactly this diff (no probes, no other changes;
compiler asserted to carry the change and not the diagnostics), with the change ON
(PERRY_L14_NBC_ORDER=1), pinned node v26.5.1:

count
pass 885
expected failures already in the snapshot (standing bugs, e.g. #2159, #2514) 5
regressions flagged by the run 2

Both flagged regressions were rerun on the same PR compiler, with the change off and on, three
runs each against node:

test change off change on
test_gap_6558_webassembly_graceful_fail PASS PASS PASS PASS PASS PASS
test_gap_9421_async_output_flush PASS PASS PASS PASS PASS PASS

Neither is caused by this change:

With the change off, the emitted run() is identical to the pre-change build — the knob is free when off.

Why land it anyway

It removes a defect that made an existing rule permanently unreachable: without it, no accumulator
whose writes read a proven field could be admitted to number_by_construction_locals, whatever the
receiver's proof. That is worth fixing on its own terms.

What it does not do, stated so a reviewer does not infer it: it does not prepare the object-model
work's region path. Versioned loop regions classify accumulators with a different walker
(stable_packed_accumulator::collect_numeric_accumulators plus a preheader admission test on each
accumulator's entry value), not with collect_numeric_by_construction_locals, which is what this PR
parameterises. The fix is a bug fix to the function-wide fact and nothing more.

Summary by CodeRabbit

  • Compiler Improvements

    • Improved numeric analysis for values accessed through proven object shapes, enabling more arithmetic patterns to be recognized during compilation.
    • Added an optional ordering configuration through the PERRY_L14_NBC_ORDER environment variable.
  • Bug Fixes

    • Updated compilation caching so changing the numeric-analysis configuration correctly rebuilds affected artifacts instead of reusing stale results.

…r proofs it depends on

`collectors/hir_facts.rs` computed `number_by_construction_locals` before
`collect_shape_proven_ptr_locals`. For `h = h + o.a` that asks "is h
Number-producing?" before o's receiver proof exists, and
`expr_numeric_by_construction`'s PropertyGet arm is gated on the receiver being
a tracked member — so the function-scope entry point passed `empty_members` /
`empty_fields` HARDCODED (ptr_shape_numeric.rs) and that arm could never fire.

The accumulator was therefore never admitted, however completely the receiver's
shape was proven. A probe on the `+` routing decision (expr/binary.rs
`both_numeric`) reports, on a fixture whose opt report says
`Ptr<Shape> 1 selected / 1 CONSUMED`:

    left=LocalGet(num=false canon=false)  right=PropertyGet(num=true canon=true)
    both_numeric=false => GUARDED

The slot is proven; the local is not. Writing the same read as `o.a * 1` makes
the accumulator's own fixpoint close (its write becomes a Binary), which is why
that spelling reaches INLINE_FADD and costs half — not because the multiply
normalises anything.

This moves the computation after the receiver proofs and threads them in. Two
parameters that were hardcoded empty become real. No new admission arm, no new
provenance class, no new fact.

The shape inputs are the INTERSECTION of the proven receivers' numeric field
sets: the arm consumes one set and does not re-check which receiver a property
belongs to, so the set must be numeric on every admitted receiver. A union
would be a wrong answer, not a weaker one.

Default OFF behind PERRY_L14_NBC_ORDER=1 and keyed into the object cache; with
it off the inputs are empty and the fixpoint computes exactly what it computed
before, so the reorder is a no-op.

Refs PerryTS#10777
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 88cdf141-cf16-493f-aa46-89f7360a8156

📥 Commits

Reviewing files that changed from the base of the PR and between 0fa3915 and 5be47b3.

📒 Files selected for processing (5)
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/collectors/number_by_construction.rs
  • crates/perry-codegen/src/collectors/ptr_shape.rs
  • crates/perry-codegen/src/collectors/ptr_shape_numeric.rs
  • crates/perry/src/commands/compile/object_cache.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The numeric-by-construction analysis now runs after shape proofs. When PERRY_L14_NBC_ORDER is enabled, it uses shape-proven receivers and shared numeric fields. The object cache key now includes this environment setting.

Changes

Numeric provenance ordering

Layer / File(s) Summary
Shape proof ordering
crates/perry-codegen/src/collectors/hir_facts.rs, crates/perry-codegen/src/collectors/ptr_shape.rs
The number-by-construction analysis runs after shape proofs. Per-receiver proof calls pass empty shape inputs to avoid mutual recursion.
Shape-aware numeric fixpoint and cache key
crates/perry-codegen/src/collectors/number_by_construction.rs, crates/perry-codegen/src/collectors/ptr_shape_numeric.rs, crates/perry/src/commands/compile/object_cache.rs
The fixpoint accepts shape-proven receivers and fields. PERRY_L14_NBC_ORDER enables derived inputs for 1, on, and true. The setting is included in the object-cache key.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant collect_type_facts
  participant collect_shape_proven_ptr_locals
  participant shape_numeric_inputs
  participant collect_number_by_construction_locals
  collect_type_facts->>collect_shape_proven_ptr_locals: collect shape proofs
  collect_type_facts->>shape_numeric_inputs: derive shape numeric inputs
  shape_numeric_inputs->>collect_number_by_construction_locals: pass receiver members and numeric fields
  collect_number_by_construction_locals-->>collect_type_facts: return numeric locals
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 9 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: computing numeric provenance after the required Ptr receiver proofs.
Description check ✅ Passed The description is detailed and relevant. It explains the defect, implementation, soundness decision, feature flag, cache behavior, test evidence, related issue, and scope. It does not use the reposit…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🧪 Generate unit tests (beta)
  • Create a new PR
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

proggeramlug pushed a commit that referenced this pull request Sep 21, 2026
#10929 keyed the knob into the OBJECT cache but not the BUILD cache, so
codegen_env_vars_are_build_cache_inputs failed (#6394's rule). The two settings
emit different code -- on, 'h = h + o.a' is admitted and the '+' routes to
INLINE_FADD; off, the shape inputs are empty and it stays guarded -- so it is a
cache input, not an exclusion.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
…issed

collect_numeric_by_construction_locals gained shape_members and
shape_numeric_fields (#10777). Four callers in
ptr_shape_group_numeric_tests.rs were not updated, so the crate failed to
compile as a test target while `cargo check --lib` stayed green -- the hole
CI's `warnings` gate (--all-targets) exists to close.

Empty sets at every updated site: that is exactly what the function computed
before the reorder, so the tests keep asserting what they asserted.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
#10929 keyed the knob into the OBJECT cache but not the BUILD cache, so
codegen_env_vars_are_build_cache_inputs failed (#6394's rule). The two settings
emit different code -- on, 'h = h + o.a' is admitted and the '+' routes to
INLINE_FADD; off, the shape inputs are empty and it stays guarded -- so it is a
cache input, not an exclusion.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main in merge train 254 (#10930, a022cf2e41, released as v0.5.1634) — your commits are on main verbatim; the train cherry-picked them rather than merging this branch, so GitHub cannot mark it merged. Closing as landed, not as rejected.

Two defects were fixed in the train, both of which would have turned CI red:

  1. The crate did not compile as a test target. collect_numeric_by_construction_locals gained shape_members and shape_numeric_fields, but four callers in collectors/ptr_shape_group_numeric_tests.rs were left at the old arity. cargo check --lib compiles no cfg(test) code so it stayed green locally; cargo check --workspace --all-targets under -D warnings — CI's warnings gate — did not. Fixed with empty sets at each site, which is exactly what the function computed before the reorder.
  2. The knob keyed the wrong cache. PERRY_L14_NBC_ORDER went into the object cache but not the build cache, so codegen_env_vars_are_build_cache_inputs (build: the object cache doesn't key on codegen env vars — PERRY_WRITE_BARRIERS=0 silently does nothing with a warm cache #6394's rule) failed on CI: an unregistered switch lets objects compiled with one setting be served to a build with the other. It is a cache input, not an exclusion — on, h = h + o.a is admitted and the + routes to INLINE_FADD; off, it stays guarded.

One thing left open, filed separately: nothing exercises the ON path. shape_numeric_inputs() early-returns empty unless the knob is set, and no test sets it — so the reorder, and the intersection rule in particular, is executed by no suite. Harmless as shipped (default-OFF, provably a no-op), but worth a test.

The train was validated as one tree: all ratchets, cargo check --workspace --all-targets under -D warnings, cargo audit (0 vulnerabilities), the 83-gate run_lint_gates.sh (only the known-red public baseline failing), 6,679 unit tests + 1,150 CLI tests + 8 acceptance tests with zero failures, both compiler-output regressions, the repsel census, and a 174-test gap sweep with no unexplained regressions. Artifacts were pinned by sha256 before the test phase and still matched after it.

proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
#10936/#10946 added PERRY_REGION_READS and PERRY_REGION_DIAG without keying
either, so codegen_env_vars_are_build_cache_inputs failed (#6394's rule).

PERRY_REGION_READS is a kill switch: =0 makes both region slices decline and
every guarded run lowers as individual reads instead of one shape compare plus
a slot load. Emitted code differs, so it is a cache INPUT.

PERRY_REGION_DIAG runs statement_run_census over the HIR and prints the counts
from ModuleDiag::drop. The census result is read in exactly one place -- that
eprintln! -- and nothing in lowering consults it, so the object is
byte-identical with the report on and off: an EXCLUSION, with the reason.

The kill switch is keyed into the OBJECT cache as well. Keying one of the two
caches is exactly what #10929 got wrong one train ago, and the gate only checks
the build cache, so the same gap was sitting here unreported.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
#10936/#10946 added PERRY_REGION_READS and PERRY_REGION_DIAG without keying
either, so codegen_env_vars_are_build_cache_inputs failed (#6394's rule).

PERRY_REGION_READS is a kill switch: =0 makes both region slices decline and
every guarded run lowers as individual reads instead of one shape compare plus
a slot load. Emitted code differs, so it is a cache INPUT.

PERRY_REGION_DIAG runs statement_run_census over the HIR and prints the counts
from ModuleDiag::drop. The census result is read in exactly one place -- that
eprintln! -- and nothing in lowering consults it, so the object is
byte-identical with the report on and off: an EXCLUSION, with the reason.

The kill switch is keyed into the OBJECT cache as well. Keying one of the two
caches is exactly what #10929 got wrong one train ago, and the gate only checks
the build cache, so the same gap was sitting here unreported.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
#10936/#10946 added PERRY_REGION_READS and PERRY_REGION_DIAG without keying
either, so codegen_env_vars_are_build_cache_inputs failed (#6394's rule).

PERRY_REGION_READS is a kill switch: =0 makes both region slices decline and
every guarded run lowers as individual reads instead of one shape compare plus
a slot load. Emitted code differs, so it is a cache INPUT.

PERRY_REGION_DIAG runs statement_run_census over the HIR and prints the counts
from ModuleDiag::drop. The census result is read in exactly one place -- that
eprintln! -- and nothing in lowering consults it, so the object is
byte-identical with the report on and off: an EXCLUSION, with the reason.

The kill switch is keyed into the OBJECT cache as well. Keying one of the two
caches is exactly what #10929 got wrong one train ago, and the gate only checks
the build cache, so the same gap was sitting here unreported.
proggeramlug pushed a commit that referenced this pull request Sep 22, 2026
#10936/#10946 added PERRY_REGION_READS and PERRY_REGION_DIAG without keying
either, so codegen_env_vars_are_build_cache_inputs failed (#6394's rule).

PERRY_REGION_READS is a kill switch: =0 makes both region slices decline and
every guarded run lowers as individual reads instead of one shape compare plus
a slot load. Emitted code differs, so it is a cache INPUT.

PERRY_REGION_DIAG runs statement_run_census over the HIR and prints the counts
from ModuleDiag::drop. The census result is read in exactly one place -- that
eprintln! -- and nothing in lowering consults it, so the object is
byte-identical with the report on and off: an EXCLUSION, with the reason.

The kill switch is keyed into the OBJECT cache as well. Keying one of the two
caches is exactly what #10929 got wrong one train ago, and the gate only checks
the build cache, so the same gap was sitting here unreported.
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