Skip to content

fix(drv): the cross-arch gate was green because it could not see the defect - #316

Open
avrabe wants to merge 7 commits into
mainfrom
fix/cross-arch-gate-strict
Open

fix(drv): the cross-arch gate was green because it could not see the defect#316
avrabe wants to merge 7 commits into
mainfrom
fix/cross-arch-gate-strict

Conversation

@avrabe

@avrabe avrabe commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Reopened from #313, which GitHub auto-closed when its base branch was deleted on #312's merge. Same branch, now targeting main so the gates actually run — the zero-checks state on #313 was an empty gate, not a pass.

The gate merged in #310 was green for the wrong reason

It asserted undefined_count > 0 over a hand-written list of 8 drivers. Both
halves were wrong, and they cancelled into a green light.

It gated 8 of 13. DRIVERS=(wdg gpio i2c timer adc dac pwm uart) while
thirteen *-thin directories exist. can, hm, mpu, spi, switch were
never checked. This is the third enumerated list this month to silently exclude
the newest thing — after check-providers.sh and the Lean target list. Now
discovered, like those.

> 0 is not the property. It is satisfied by any undefined symbol,
including a leaked one — and hm-thin imports nothing, so a correct object
has zero undefined symbols and would fail.

The rule is equality: the object's undefined set must equal the names the wasm
imports. Same rule check-driver-components.py already applies on its object
axis.

What the correct rule finds

result
ARM 13/13 clean — undefined set == imports, exactly. No leak, no truncation.
RISC-V 0/13.
Full table (re-measured, synth 0.58.0)
driver imports ARM cortex-m3 RISC-V rv32imc
adc-thin read32 write32 45 T, 2 U — complete 26 T, 3 U — 1 of 27 skipped, 1 dangling
can-thin read32 write32 34 T, 2 U — complete 19 T, 3 U — 1 of 20 skipped, 1 dangling
dac-thin read32 write32 43 T, 2 U — complete 24 T, 3 U — 1 of 25 skipped, 1 dangling
gpio-thin read32 write32 30 T, 2 U — complete 17 T, 3 U — 1 of 18 skipped, 1 dangling
hm-thin (none) 19 T, 0 U — complete 12 T, 1 U — 1 of 13 skipped, 1 dangling
i2c-thin read32 write32 38 T, 2 U — complete 21 T, 3 U — 1 of 22 skipped, 1 dangling
mpu-thin mpu-write 27 T, 1 U — complete no object — exits non-zero (#952)
pwm-thin write32 36 T, 1 U — complete 21 T, 2 U — 1 of 22 skipped, 1 dangling
spi-thin read32 write32 34 T, 2 U — complete 19 T, 3 U — 1 of 20 skipped, 1 dangling
switch-thin ctx-resume ctx-save region-swap 41 T, 3 U — complete 17 T, 10 U — 13 of 30 skipped, 10 dangling
timer-thin read32 write32 30 T, 2 U — complete 17 T, 3 U — 1 of 18 skipped, 1 dangling
uart-thin poll read32 write32 27 T, 3 U — complete 16 T, 4 U — 1 of 17 skipped, 1 dangling
wdg-thin read32 write32 35 T, 2 U — complete 20 T, 3 U — 1 of 21 skipped, 1 dangling

Twelve RISC-V objects cannot be linked: synth's RV32 selector skips a function
it cannot select and emits the call site anyway, leaving a dangling
synth_func_N (definitions are named func_N, so it never resolves). On ARM
the same internals are emitted as local t func_N, all defined — the defect is
specific to the RISC-V backend.

gpio-thin: skipping 'func_18': RISC-V selector: immediate 1048588 too large for memory offset
mpu-thin:  skipping 'func_20': unsupported wasm op for RV32 skeleton: GlobalGet(0)

For a skipped internal function synth exits 0, so the unlinkable object
ships with only a warning. That is exactly what let > 0 read green.

Not a regression. Bisected 0.52.0 / 0.53.0 / 0.54.0 / 0.55.0 / 0.57.0 /
0.58.0 — all produce identical results on today's sources. Filing upstream
separately.

The RISC-V leg is a ledger, not a skip

If a driver starts crossing cleanly the gate fails (exit 5) until it is
removed from RISCV_KNOWN_BAD. The list shrinks to empty instead of outliving
the bug. A driver that newly breaks also fails.

CROSS-ARCH.md was stale, not wrong-at-the-time

It measured pre-componentization sources — wdg-thin had 6 functions then, 21
now — and was never re-measured when they changed. Re-measured here. The
section that justified > 0 is replaced by one explaining why it was the wrong
rule.

Also: a duplicate gate I created

check-driver-components.sh already existed — a red-first shell gate for
REQ-DRV-COMPONENT-001 — when I wrote the .py for the same requirement. Two
gates for one requirement is itself a drift hazard, and the .sh was wired to
nothing. Removed; its red-first framing is preserved in the .py.

Kill-criterion

check-cross-arch.py --self-test must reject a leaked-symbol superset and must
pass an empty expectation:

  self-test ok: leaked symbol (superset) is rejected
  self-test ok: a driver importing nothing passes with 0 undefined

Both run in CI as a step before the gate.

🤖 Generated with Claude Code

https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo

avrabe and others added 5 commits August 28, 2026 05:50
…defect

The gate merged in #310 asserted `undefined_count > 0` over a hand-written list
of 8 drivers. Both halves were wrong, and they cancelled into a green light.

**It gated 8 of 13.** `DRIVERS=(wdg gpio i2c timer adc dac pwm uart)` while
thirteen `*-thin` directories exist — can, hm, mpu, spi and switch were never
checked. Third enumerated list this month to silently exclude the newest thing;
now discovered, like check-providers.sh and the Lean target list before it.

**`> 0` is not the property.** It is satisfied by ANY undefined symbol,
including a leaked one. Every RISC-V object carries a dangling `synth_func_N`,
so every one of them counted as passing. And `hm-thin` imports nothing, so a
CORRECT object has zero undefined symbols — under `> 0` a correct driver fails.

The rule is equality: the object's undefined set must equal the names the wasm
imports. Same rule check-driver-components.py already uses on its object axis.
Correct for a driver importing nothing, for a seam that is not gust:hal
(mpu-thin -> mpu-write, switch-thin -> the three ctx symbols), and it rejects
a leak.

What the correct rule finds, across all 13:

  ARM     13/13 clean. Undefined set == imports, exactly. No leak, no
          truncation. That half of the cross-arch claim holds.
  RISC-V   0/13. Twelve emit an unlinkable object — synth's RV32 selector
          skips a function it cannot select and emits the call site anyway,
          leaving `synth_func_N` dangling (definitions are named `func_N`, so
          it never resolves). mpu-thin emits nothing: GlobalGet(0) is
          unsupported by the RV32 skeleton, 8 of 20 functions skipped, and
          synth then exits non-zero (#952).

For a skipped INTERNAL function synth exits 0, so the unlinkable object ships
with only a warning. That is what let `> 0` read green.

Not a regression: synth 0.52.0 produces byte-identical results to 0.58.0 on
today's sources (bisected 0.52/0.53/0.54/0.55/0.57/0.58). The RISC-V leg is
carried as an explicit ledger — if a driver starts crossing cleanly the gate
FAILS until it is removed from the list, so the ledger shrinks to empty
instead of outliving the bug.

CROSS-ARCH.md's table was stale, not wrong-at-the-time: it measured
pre-componentization sources (wdg-thin had 6 functions then, 21 now) and was
never re-measured when they changed. Re-measured here, and the section
explaining why `> 0` was the wrong rule replaces the one that justified it.

Also removes check-driver-components.sh — a second, unreferenced gate for
REQ-DRV-COMPONENT-001 that I duplicated in .py without noticing. Two gates for
one requirement is itself a drift hazard. Its red-first framing was right and
is preserved in the .py.

build-cross-arch.sh is kept as a reporting tool (it prints byte sizes) and as
the subject of the workflow's pin-guard negative control, marked superseded as
a gate, and now discovers drivers rather than enumerating them.

Kill-criterion: `check-cross-arch.py --self-test` must reject a leaked-symbol
superset and must PASS an empty expectation. Both verified, and both run in CI
before the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
Re-tested the RISC-V leg on synth 0.60.0 (the latest release, our pin is
0.58.0): 12 of 13 drivers still exit 0 while emitting a dangling
`synth_func_N`, and `ld.lld` refuses to link them —

  ld.lld: error: undefined symbol: synth_func_18
  >>> referenced by out.o:(gust:hal/gpio@0.1.0#set)

referenced by two EXPORTED entry points, so no gc-sections pass drops it.
Bumping the pin would not clear the ledger.

Filed as synth#1102. The premise of synth#1013 — that ARM and RISC-V decline
cleanly where aarch64 panicked — does not hold when the declined target is an
internal rather than a requested export: the #952 export guard never fires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
…m this PR disproves

The header said: 'On the pinned toolchain every driver crosses and the gate
exits 0.' That was the `> 0` rule reading a dangling `synth_func_N` as a seam
symbol. On the equality rule ARM holds for all 13 and RISC-V holds for none
(synth#1102).

Also states why the rule is neither '> 0' nor '== 0' but equality, and that the
RISC-V ledger fails on a status change in either direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
…at does not link

The v0.7.1 plan scopes this item as 'RISC-V from compiled to executed', on the
premise that the objects already compile correctly and only lack a silicon run.
They do not compile correctly. 0 of 13 drivers cross RISC-V on the corrected
gate rule; ld.lld refuses every object (synth#1102, confirmed on 0.60.0).

Records the blocker and the split that keeps the item shippable: the
target-model half (generated RV32 target, memory.x, constants) is independent
of the selector bug and can proceed; only the 'executed' half waits upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
…er installed

check-cross-arch.py derives each driver's EXPECTED undefined set from the wasm's
import section, so it needs wasm-tools. The old shell gate only ran nm, so
drv-cross-arch.yml never installed it — this PR's gate would have exited 2 with
'FATAL: wasm-tools not usable' on the first CI run.

Pinned to 1.245.1, the same version drv-components.yml uses, so both gates read
imports through identical tooling.

Also hardens synth_bin(): it shelled out to `varve which synth` unguarded, which
raises FileNotFoundError where varve is not installed. CI never hit that path
(it sets $SYNTH, checked first), but a local run without either would have
crashed instead of falling back to PATH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

avrabe and others added 2 commits August 28, 2026 06:39
…ted object

Deleting check-driver-components.sh in this PR removed the only gate that
globbed dma-own. That was a silent coverage regression, and it is the exact
failure this PR is about — a gate whose scope excludes something without
saying so.

Restoring it surfaced two things that were invisible while nothing looked:

1. dma-own DOES NOT BUILD on rustc 1.97.0. rust-lld rejects the three raw
   externs (undefined symbol: dma_barrier / dma_irq_poll / dma_program). It
   has no .cargo/config.toml, unlike the five thin drivers that carry
   --allow-undefined for exactly this. Fixed by adding one. My earlier
   'it builds' reading was a stale artifact from an older toolchain — the
   build had failed and I had sent stderr to /dev/null.

2. dma-own is NOT componentized. Its three TCB atoms land as raw TERM_SESSION_ID=w0t5p0:7E6A1C62-B482-423B-857E-357D1616A66E
SSH_AUTH_SOCK=/var/run/com.apple.launchd.2B1x5eSyt7/Listeners
LC_TERMINAL_VERSION=3.6.11
COLORFGBG=0;15
ITERM_PROFILE=Default
OSLogRateLimit=64
XPC_FLAGS=0x0
LANG=en_US.UTF-8
PWD=/Volumes/Home/git/pulseengine/gale
SHELL=/bin/zsh
__CFBundleIdentifier=com.googlecode.iterm2
SECURITYSESSIONID=186a2
TERM_FEATURES=T3LrMSc7UUw9Ts3BFGsSyHNoSxFP
TERM_PROGRAM_VERSION=3.6.11
TERM_PROGRAM=iTerm.app
PATH=/Users/r/.wasmtime/bin:/Users/r/.opencode/bin:/Users/r/.nvm/versions/node/v20.19.2/bin:/Users/r/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/Users/r/.elan/bin:/Users/r/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/opt/zerobrew/prefix/bin:/Users/r/.zerobrew/bin:/Users/r/.cargo/bin:/Applications/iTerm.app/Contents/Resources/utilities:/Users/r/.lmstudio/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/rust-analyzer-lsp/1.0.0/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/frontend-design/unknown/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/commit-commands/unknown/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/superpowers/6.3.0/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/github/unknown/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/security-guidance/2.0.7/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/playwright/unknown/bin:/Users/r/.claude/plugins/cache/claude-plugins-official/clangd-lsp/1.0.0/bin:/Users/r/.claude/plugins/cache/pulseengine-eu/pulseengine-claude/0.20.0/bin
LC_TERMINAL=iTerm2
COLORTERM=truecolor
COMMAND_MODE=unix2003
TERM=xterm-256color
TERMINFO_DIRS=/Applications/iTerm.app/Contents/Resources/terminfo:/usr/share/terminfo
HOME=/Users/r
TMPDIR=/var/folders/yp/nb2yryq12f9bnx48xdh11n8w0000gn/T/
USER=r
XPC_SERVICE_NAME=0
LOGNAME=r
LaunchInstanceID=F7BFEA73-4019-4630-9B3D-8A3BC077C845
__CF_USER_TEXT_ENCODING=0x0:0:0
ITERM_SESSION_ID=w0t5p0:7E6A1C62-B482-423B-857E-357D1616A66E
SHLVL=2
OLDPWD=/Volumes/Home/git/pulseengine/gale/benches/gust/drivers
ZEROBREW_DIR=/Users/r/.zerobrew
ZEROBREW_BIN=/Users/r/.zerobrew/bin
ZEROBREW_ROOT=/opt/zerobrew
ZEROBREW_PREFIX=/opt/zerobrew/prefix
PKG_CONFIG_PATH=/opt/zerobrew/prefix/lib/pkgconfig:/opt/zerobrew/prefix/lib/pkgconfig:
HOMEBREW_PREFIX=/opt/homebrew
HOMEBREW_CELLAR=/opt/homebrew/Cellar
HOMEBREW_REPOSITORY=/opt/homebrew
FPATH=/opt/homebrew/share/zsh/site-functions:/usr/local/share/zsh/site-functions:/usr/share/zsh/site-functions:/usr/share/zsh/5.9/functions
INFOPATH=/opt/homebrew/share/info:
__ETC_PROFILE_NIX_SOURCED=1
NIX_PROFILES=/nix/var/nix/profiles/default /Users/r/.nix-profile
XDG_DATA_DIRS=/usr/local/share:/usr/share:/Users/r/.nix-profile/share:/nix/var/nix/profiles/default/share
NIX_SSL_CERT_FILE=/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt
NVM_DIR=/Users/r/.nvm
NVM_CD_FLAGS=-q
NVM_BIN=/Users/r/.nvm/versions/node/v20.19.2/bin
NVM_INC=/Users/r/.nvm/versions/node/v20.19.2/include/node
WASMTIME_HOME=/Users/r/.wasmtime
NoDefaultCurrentDirectoryInExePath=1
COREPACK_ENABLE_AUTO_PIN=0
AI_AGENT=claude-code_2-1-245_agent
CLAUDE_CODE_ENTRYPOINT=cli
CLAUDE_CODE_MESSAGING_SOCKET=/tmp/cc-socks/30731.sock
CLAUDE_CODE_MESSAGING_TOKEN=8aaee9c7b3ab23e93412e343f5b28d9d
CLAUDE_CODE_BRIDGE_SESSION_ID=session_011QG86sovTbfnPNY9SfhSmo
GIT_EDITOR=true
CLAUDE_CODE_EXECPATH=/Users/r/.local/share/claude/versions/2.1.245
CLAUDECODE=1
CLAUDE_CODE_SESSION_ID=b9703d91-674b-4409-82ca-8c80975abd51
CLAUDE_CODE_CHILD_SESSION=1
CLAUDE_PID=30731
CLAUDE_EFFORT=high
_=/usr/bin/env
   imports, not a typed WIT interface the way all 13 thin drivers do. That
   was the deleted shell gate's EXPECTED red — its header said so — but it
   was wired to no workflow, so the red was never seen.

Whether dma-own is in REQ-DRV-COMPONENT-001's scope is a genuine question
(the requirement says 'every thin-seam driver'; dma-own is an ownership FSM).
That is a scope decision and is NOT made here. Its actual state is asserted
instead: the gate fails if its raw env set changes in either direction.

Adds a census over every committed *-cm3.o. Nine live outside the gated set —
breadth, dma-own, os-node (5), and two providers. The composed/fused ones are
not 1:1 lowerings, so gating them by this rule would produce false failures;
they are LISTED instead. A committed object that is neither gated nor listed
fails the gate, and so does a ledger entry whose object has vanished.

Kill-criteria, both directions verified:

  ledger names a nonexistent object      -> exit 5, names it
  new committed object appears unlisted  -> exit 5, names it
  restored tree                          -> exit 0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
…pecific

synth#1102 is fixed on main as #1104, and the fix corrects this PR's central
inference. I reported the defect as rv32-specific and cited the clean ARM column
as support. That was wrong: Thumb-2 and A32 shipped the identical dangling symbol
at exit 0 on a module that declines on those backends. The guard was missing
everywhere.

Our ARM objects are clean only because the ARM backend DECLINES NOTHING on this
corpus, so the broken path is never entered. 'ARM 13/13, undefined set equals
imports' remains a true measurement of those objects; it is not evidence that the
ARM lowering path is sound, and the gate comment now says so explicitly and warns
against upgrading it into one.

Both sides reached the same wrong conclusion from a probe artefact — upstream
from get_section_by_name('.symtab') returning nothing on synth's ARM builder,
here from a corpus that happens not to exercise the path.

The ledger stays: #1104 is on main, not in a release (0.60.0 is the latest tag),
and our pin is 0.58.0. When we pin a build carrying it, the ledger fails and
forces itself to shrink rather than outliving the bug.

Also searched our corpus for the residual case they asked for (a declined
function referenced only from a funcref table entry) and reported the negative
result: switch-thin and mpu-thin both carry call_indirect and both decline
heavily, but neither has any overlap between declined indices and elem-referenced
ones. That incidentally explains why switch-thin declines 13 functions but shows
only 10 dangling symbols — the other three are dead, not table-referenced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QG86sovTbfnPNY9SfhSmo
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