Skip to content

fix(zig): a declared width beats an inferred one (+32 on the deep ruler, +0 on the corpus column, 0 regressions) - #2963

Merged
gHashTag merged 2 commits into
masterfrom
w795
Aug 30, 2026
Merged

fix(zig): a declared width beats an inferred one (+32 on the deep ruler, +0 on the corpus column, 0 regressions)#2963
gHashTag merged 2 commits into
masterfrom
w795

Conversation

@gHashTag

Copy link
Copy Markdown
Owner

The defect

Zig has no comptime_int << runtime, so a shift whose amount is not comptime-known must
carry a width on its left operand. With nothing else to go on the emitter used
zig_int_literal_default_type, which reads the literal's magnitude: 1 fits in u32,
therefore u32.

var half: i32 = @as(u32, 1) << @intCast(d);   // specs/ternary/gft_add_rne.t27

A u32 expression initialising an i32. Zig rejects it — and the type it needed was written
two lines above the expression that had to guess it.

Where the shape actually sits

58 emitted sites across 47 specs carry @as(u32, 1) <<. Classified by position rather
than by message:

sites
declaration that states its type 33 all i32 — the defect
declaration with no type 13 coerces; not touched
inside a larger expression (a while condition, an operand) 10 not touched
return 2 not touched

(#2952 said 33 files. That was the intersection with the 60 files #2951 unblocked; the
whole population carrying the shape is 47 specs / 58 sites, and the sub-population this
change repairs is the 33 typed declarations.)

The fix

Record the declaration's own integer type for the length of its initializer, and prefer it
over the magnitude default. A declared width beats an inferred one — the same rule
zig_int_literal_default_type already applies to a literal's suffix, applied one level
out.

Order of precedence, and each step has a test:

  1. the literal's own suffix (1u32) — most specific, unchanged;
  2. the declaration's stated integer type — new;
  3. the magnitude default — the old behaviour, still the fallback.

zig_declared_int_type returns None for anything that is not an integer, so a f64 or a
struct falls back rather than emitting @as(SomeStruct, 1) and trading one rejected file
for another.

Measurement — per spec, both directions, zig 0.16.0, no timeouts

ruler before after new regressions
zig test --test-no-exec -femit-bin=… 133 165 +32 0
zig build-obj -fno-emit-bin 282 282 +0 0
cc -fsyntax-only -std=gnu11 (control) 268 268 +0 0

The second row was predicted before the fix and is the honest part. #2952 says it
outright: the corpus acceptance column is measured with build-obj, which resolves
identifiers and never Sema-analyses a body nothing references, so it cannot see this defect
and does not move when it is repaired. Quoting only the +32 would overstate what this
repository's own column will show; quoting only the +0 would say a real repair did nothing.

The +32 is exactly the number an adversarial verifier derived independently on 2026-08-30,
before this code existed, when it measured how many of the 60 files unblocked by the
assert_eq shim were still held by something else.

The C row is the control: the change is confined to the Zig expression emitter, and cc is
unmoved in both directions.

A contaminated baseline was thrown away. The first test-no-exec baseline was taken by
a background job that was still running while the compiler was rebuilt, so an unknown
suffix of its specs went through the new binary. It happened to agree with the clean
re-measurement at 133, and it was discarded anyway: a number that could have been wrong is
not made right by turning out correct.

Mutation testing

mutant killed by
M1 drop the declared-type preference the_declared_type_pins_…, a_wider_declaration_wins_…
M2 drop the suffix guard a_suffixed_literal_beats_the_declaration
M3 never clear the hint the_hint_does_not_leak_past_its_declaration
M4 accept non-integer declared types a_non_integer_declaration_falls_back_…

M3 survived the first time, and that is the useful part. The original leak test put a
second declaration after the first — and an untyped declaration overwrites the hint with
None on its way in, so removing the explicit clear changed nothing the test could see. It
asserted a true thing about a case the clear never affects.

What bites is a shift that is not a declaration: return 1 << e in a u32-returning function
emits @as(i32, 1) when the previous statement's hint survives. Verified against the mutant,
which produces exactly that. The two shift amounts differ on purpose — with the same amount
twice, common-subexpression elimination hoists a single _cse1 and there is only one shift
left to be wrong about.

Closes #2952


Closes #2952

🤖 Generated with Claude Code

gHashTag and others added 2 commits August 31, 2026 00:07
Zig has no `comptime_int << runtime`, so a shift with a runtime amount
must pin a width on its left operand. With nothing else to go on the
emitter read the literal's MAGNITUDE -- `1` fits u32, therefore u32 --
and produced

    var half: i32 = @as(u32, 1) << @intcast(d);

a u32 expression initialising an i32. The type it needed was written two
lines above the expression that had to guess it.

58 emitted sites across 47 specs carry the shape. By position: 33 are a
declaration that states its type (all i32, the defect), 13 an untyped
declaration, 10 inside a larger expression, 2 a return. Only the 33 are
touched. #2952's "33 files" was the intersection with the 60 files #2951
unblocked; the population carrying the shape is 47 specs.

The declaration's integer type is now recorded for the length of its
initializer and preferred over the magnitude default. Precedence, each
step with a test: the literal's own suffix, then the declaration's type,
then magnitude. A declared type that is not an integer returns None and
falls back, so a f64 or a struct does not become `@as(SomeStruct, 1)`.

Measured per spec, both directions, zig 0.16.0, no timeouts:

    zig test --test-no-exec     133 -> 165   +32   regressions 0
    zig build-obj -fno-emit-bin 282 -> 282    +0   regressions 0
    cc -fsyntax-only (control)  268 -> 268    +0   regressions 0

The second row was predicted before the fix and is the honest part: the
corpus column is measured with build-obj, which never Sema-analyses a
body nothing references, so it cannot see this defect and does not move
when it is repaired. The +32 is the number an adversarial verifier
derived independently a day earlier, before this code existed.

A contaminated baseline was thrown away: the first test-no-exec reading
came from a background job still running while the compiler was rebuilt.
It agreed with the clean re-measurement at 133 and was discarded anyway
-- a number that could have been wrong is not made right by turning out
correct.

Four mutants. M3 SURVIVED the first time, which is the useful part: the
leak test put a second DECLARATION after the first, and an untyped
declaration overwrites the hint with None on its way in, so removing the
explicit clear changed nothing it could see. What bites is a return --
`return 1 << e` in a u32-returning fn emits `@as(i32, 1)` when the
previous hint survives -- with a different shift amount, because with the
same amount twice CSE hoists one `_cse1` and there is only one shift left
to be wrong about.

Skill 399: `tail -N` reads the last section, not the summary. Reading
`tri seals drift --fix` that way produced two different wrong
conclusions on consecutive days, one of which reached a merge commit
message. Grep the label, and settle a count against the filesystem.

Closes #2952

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	.claude/skills/ci-gates/SKILL.md
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 17:21:22 UTC

Summary

Status Count
Total Open PRs 8
PRs with Failing Checks 7
PRs with All Checks Green 1
READY 0
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=69d5f0a49f87 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag
gHashTag merged commit 494265f into master Aug 30, 2026
36 checks passed
gHashTag added a commit that referenced this pull request Aug 30, 2026
…2967)

Yesterday a change unblocked 32 specs under `zig test --test-no-exec`
and moved this table's Zig column by exactly 0 (#2952, PR #2963).

The zero is correct. `zig build-obj -fno-emit-bin` resolves identifiers
and never Sema-analyses a function nothing references, so a defect inside
a body is invisible to it and stays invisible when repaired. The
consequence is that no ratchet can go red for that class, and no repair
of it can be credited: a real fix reports as a fix that did nothing.

The gap is not small. 282 accept under build-obj, 165 under the deeper
ruler -- 117 files are counted as Zig-accepted today while carrying
something the compiler would reject the moment it looked.

So: a second row, `... and Zig ANALYSES it`, carried into --json
(wave-to-wave comparison reads the JSON; a row only in the human table is
a number nothing can diff) and into --per-spec, whose Zig field is three
digits now with the header renamed to say so.

`-femit-bin` is given a REAL path. `-femit-bin=/dev/null` fails on every
input with "failed to invalidate kernel cache: PermissionDenied", which
would report 0 of 589 and read as a catastrophic regression rather than
as a broken ruler. That cost a re-run while measuring #2952 by hand and
is written into the code beside the flag.

Three tests, cheap on purpose, asserting the SHAPE and the INVARIANT
rather than any count -- counts move whenever the emitter improves and a
test pinned to one gets re-blessed rather than read:

    bodies <= build <= gen        killed by `o.zig_bodies = true`
    the column reaches the JSON   killed by dropping the field
    three digits in --per-spec    killed by reverting the format

The subset invariant is the load-bearing one: a file the deeper ruler
accepts must pass the shallower one, because analysing a declaration is
strictly more work than resolving its name. A violation means a broken
ruler, not an improved compiler. Measured before being asserted -- it
holds on a 150-spec sample. With zig absent the tests print SKIPPED and
say so; a green meaning "not run" is what this change exists to prevent.

Skill 401: a scratch file shared with your own background agents is a
moving population. /tmp/specs.txt was regenerated by an agent between the
moment a measurement printed 649 and the moment it used the file, which
by then held 665. The A/B survived because both sides ran after the
change -- luck, not design -- and the tell was on screen: GEN 589 where
the previous day's identical command printed 581, beside a population
line still reading 649.

Also corrected there: the corpus population is 665, not the 650 several
reports including mine have quoted, and cc acceptance is 268 of 589
generating rather than 268 of 650. Numerator right, denominator wrong,
everywhere.

Closes #2966
Refs #2952

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

Zig backend: 1 << n is lowered as @as(u32, 1) regardless of the declared type (33 files)

1 participant