Skip to content

A3: one error model — validate() prechecks, rejections through tap::dsp::raise, every header under -fno-exceptions - #81

Merged
tap merged 4 commits into
mainfrom
claude/sample-rate-solutions-comparison-mqc190
Oct 11, 2026
Merged

tap merged 4 commits into
mainfrom
claude/sample-rate-solutions-comparison-mqc190

Conversation

@tap

@tap tap commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

What this changes

Roadmap A3 (docs/ROADMAP.md, row P4). Each engine now has a noexcept validate(...) that returns the rejection reason as a const char*, or nullptr when the converter will construct. The constructors reject with that same text through tap::dsp::raise (tap/DspTap#59): it throws where the build has exceptions and calls std::terminate() where it does not. Every public header now compiles under -fno-exceptions. The family's error model is written down as PLAN.md D17.

  • async: config::validate(cfg). It also covers what the bank's designer rejects and the FIFO's high-water relation, so a config that validates constructs with no throw other than bad_alloc. polyphase_filter.h rejects through raise.
  • bridge: basic_converter<S, D, K>::validate(channels, profile), built on validate_profile<D, K>.
  • rational: basic_stage<S, R>::validate(channels, profile) and basic_chain<...>::validate(channels, profile) (the stages are checked at their design divisors; the first reason wins). design.h's validate(profile) now returns the reason instead of throwing. A spec that no filter length meets still raises std::runtime_error (k_unmeetable_spec_reason).
  • C ABIs (all three): validate before constructing and return NULL. They keep their catch (for a refused allocation) only under TAP_DSP_HAS_EXCEPTIONS.
  • Pico firmwares (pico2_cyccnt, pico2_dualcore): they call validate first, then construct. They build with the SDK's default -fno-exceptions (-DPICO_CXX_ENABLE_EXCEPTIONS=0) and still build with exceptions on.
  • Gates:
    • <engine>.Family.HeadersCompileWithoutExceptions: every public header compiled in its own translation unit under -fno-exceptions, on hosted GCC and Clang.
    • A new no-exceptions CI job: clang, every engine and the three C ABIs, warnings fatal.
    • The Pico job builds each firmware a second time with exceptions off.
  • DspTap pin: moved to 6eaca2d (expects.h: tap::dsp::raise, the construction-time rejection without exceptions DspTap#59 as merged on its main).

Why

Every engine's constructor rejected a bad configuration by throwing, so no header compiled under -fno-exceptions. That flag is the default of the Pico SDK, Zephyr and most Cortex-M firmware trees, and our own Pico firmwares had to force exceptions on. Firmware needs a way to reject a configuration without exceptions, and hosts should keep the exception they rely on today.

Verification

All of this was run locally on this branch's head, which is main 442a09b (A1 merged) plus this PR's commits:

  • Host build, clang, warnings fatal on all three engines: 298/298 ctest pass, including the three HeadersCompileWithoutExceptions entries.
  • The no-exceptions job's configure, run by hand (clang, -fno-exceptions, the three C ABIs on, warnings fatal): builds clean.
  • scripts/check_retired_words.py: clean.
  • clang-format: clean on every changed C/C++ file.
  • Before the branch restart (same tree apart from the base): clang-tidy clean on the changed translation units, and both Pico firmwares built both ways (with exceptions and without).
  • Tests: in every engine, validate() agrees with the constructor on every rejection. It returns the same text the exception carries, and nullptr exactly where construction succeeds (async ValidateAgreesWithTheConstructorOnEveryRejection covers 16 rejections; bridge has Converter.ValidateAgreesWithTheConstructor; rational has Stage and Chain versions). The existing throw tests now also pin the texts.
  • Ratchet (scripts/icount.py, gcc-arm-none-eabi 13.2.1 and qemu-system-arm 8.2.2 for M33/M55, CodeLinaro clang 19.1.5 and qemu-hexagon 8.2.2 for Hexagon):
    • rational: re-measured after the restart against the A1 baselines. Every row is exact on M33, M55 and Hexagon.
    • bridge: exact on every target.
    • async: every row moves by one construction-side constant per target (M55 +37, Hexagon +32 to +34, M33 +182 instructions). That is the precheck's cost at construction, inside tolerance, so the baselines are not re-recorded.
  • Rational M33 battery under qemu, run locally: passes in 9 min 2 s, with Stage.ValidateAgreesWithTheConstructor at 57 s.
  • CI: all 31 checks green on 3aa9faa, including Windows MSVC, macOS and the arm64 leg, which were never run locally.

Notes for the reviewer

  • Contract change. tap::sr::rational::validate(profile) used to return void and throw; it now returns the reason and never throws. A caller that relied on it throwing must now test its result. The constructors still throw the same types with the same texts on hosts. Under -fno-exceptions a rejected construction terminates, so firmware calls validate() first.
  • Submodule pin moved. submodules/dsptap now points at 6eaca2d, expects.h: tap::dsp::raise, the construction-time rejection without exceptions DspTap#59 as merged on DspTap's main, so the pin is already reachable after branch cleanup.
  • A finding (in rational/PLAN.md §6). The first version of DspTap's raise was an out-of-line [[noreturn]] template. That was enough for GCC to stop inlining rational's stage constructor, which then stopped feeding the channel count into the streaming loops as a constant. The cost was +11 to +37 % on the M55 rows and +16 to +51 % on the M33 Q15 rows. Forcing raise inline (DspTap's TAP_DSP_RAISE_INLINE) brings every row back to exact. So this engine's ratchet is sensitive to inlining decisions at construction, and future constructor changes should keep that in mind.
  • Three CI fixes after the first push, each its own commit:
    • d218cb7: rational/tests/test_matrix.cpp builds with /bigobj under MSVC only. A3's new functions took its 182 instantiations past COFF's section limit (C1128).
    • c8843f5: the Hexagon legs now skip every ValidateAgrees* test, alongside the other throwing tests, because qemu-hexagon's static-musl toolchain cannot unwind a throw. book/src/part4/hexagon.md's ctest command has the same change.
    • 3aa9faa: the unmeetable-spec case moves into its own test, Stage.ValidateAgreesWithTheConstructorOnAnUnmeetableSpec, and the bare-metal legs skip it beside Design.UnmeetableSpecThrows. It searches the design grid for every filter length twice, and on the M33's soft-float double that outlasted the 20-minute job.
  • The roadmap marks A3 landed and P4 ✓ for all three engines. The Hexagon throw is documented in async/docs/PERFORMANCE.md and async/PLAN.md: A3 makes it unreachable in an embedded deployment, and A2's time-boxed unwinder attempt is still open.

🤖 Generated with Claude Code

https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf

…sp::raise, every header under -fno-exceptions

Every engine's constructor validated by throwing, so no header compiled
under -fno-exceptions, the default of the Pico SDK, Zephyr and most
Cortex-M firmware trees, and the Pico firmwares forced exceptions on.
This is the family's error model (PLAN.md D17): construction is where an
engine rejects, processing stays noexcept, and each engine states the
rejection twice in the same words. validate(...) is a noexcept precheck
returning the reason as a const char* (nullptr when the converter
constructs): async::config::validate(cfg), which now also states what the
bank's designer rejected and the FIFO's high-water relation, so a
validated config constructs without a throw other than bad_alloc;
bridge::basic_converter<S, D, K>::validate(channels, profile) over
validate_profile<D, K>; rational::basic_stage<S, R>::validate(channels,
profile) — the existing validate(profile) now returns the reason instead
of throwing — and basic_chain<...>::validate, the stages' at their design
divisors, first reason wins. The constructors raise that reason through
tap::dsp::raise (std::invalid_argument; std::runtime_error for a rational
spec no length meets, k_unmeetable_spec_reason): a throw where the build
has exceptions, std::terminate() where it does not, so such a build calls
validate() first. The DspTap pin moves to the tree that carries raise
(tap/DspTap#59, merged as 6eaca2d on its main, the identical tree).

The three C ABIs validate before constructing and return NULL, and carry
their catch (a refused allocation) only where TAP_DSP_HAS_EXCEPTIONS; the
Pico firmwares build with the SDK's -fno-exceptions default
(-DPICO_CXX_ENABLE_EXCEPTIONS=0: validate, then construct; a refused
allocation is the hard fault every allocation's is there) and still with
exceptions on, where the 12-channel case keeps its SKIP. Gates:
<engine>.Family.HeadersCompileWithoutExceptions (every public header in
its own TU under -fno-exceptions, hosted GCC and Clang), the no-exceptions
CI job (clang, every engine and the three C ABIs, warnings fatal) and the
Pico job's second build of each firmware. Tests: in every engine,
validate() agrees with the constructor on every rejection — the same text
the exception carries, nullptr exactly where it constructs — and the
existing throw tests also pin the texts.

Ratchet, measured on M33, M55 and Hexagon against the A1 baselines: bridge
and rational exact on every target; async moves by one construction-side
constant per target on every row (M55 +37, Hexagon +32 ... +34, M33 +182:
the precheck's cost at construction), inside tolerance, not re-recorded.
The first tree moved rational's M55 streaming rows +11 ... +37 % and its
M33 Q15 rows +16 ... +51 % with no change to the hot path: GCC kept raise
out of line and stopped inlining the stage's constructor into main, which
lost the channel constant the streaming loops were specialized on;
bisected to that one call, and exact again with raise forced inline, which
DspTap now does. rational/PLAN.md section 6 records the finding and the
lever it points at (a compile-time channel count or 1/2-channel fast
paths); a consumer constructing behind a boundary GCC does not inline
through runs the un-specialized loops today.

Also: the root README's "Errors" paragraph (the shared vocabulary), each
engine README's construction sentence, async's PERFORMANCE.md and PLAN.md
Hexagon-exceptions entries (deployments validate first; the unwinder stays
A2's).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
A3 took test_matrix.cpp's 182 chain instantiations past COFF's 2^16
sections on the Windows MSVC leg (C1128). /bigobj applies to that one
source and to MSVC only; every other compiler sees an empty option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
…throws

Each engine's ValidateAgreesWith* test catches every constructor rejection
to compare its text with validate()'s, and qemu-hexagon's static-musl
toolchain cannot unwind a throw (async/docs/PERFORMANCE.md, Known debt):
rational's two aborted on the leg. They join the EXPECT_THROW tests in
all three engines' Hexagon exclusions; the M33/M55 and host legs run them.
The book's Hexagon command gains the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
…bare metal

Stage.ValidateAgreesWithTheConstructor's last case searched every length
on the design grid twice (validate, then the constructor) for a profile
no length meets; on the Cortex-M33's soft-float double that outlasted the
20-minute job. The case moves to
Stage.ValidateAgreesWithTheConstructorOnAnUnmeetableSpec, which the
bare-metal filter excludes by name beside Design.UnmeetableSpecThrows (the
same search, excluded for the same reason); the host legs run it, and the
Hexagon legs exclude every ValidateAgrees* already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
@tap
tap merged commit 757f80c into main Oct 11, 2026
31 checks passed
@tap
tap deleted the claude/sample-rate-solutions-comparison-mqc190 branch October 11, 2026 12:32
tap pushed a commit that referenced this pull request Oct 12, 2026
Q1: the bench is USB asynchronous mode, with the Pico 2 as the
asynchronous-mode device on its own crystal (its USB audio firmware joins
A11), and it runs twice, so the timestamp servo is a 1.0 item (B3, C1, the
verdict rule). Q2: no outside consumer links a C ABI; MAJOR is the ABI;
FetchContent and add_subdirectory are the only supported consumption at
1.0 (C3). Q3: as landed in A1 (#80). Q4: DspTap tagged independently, the
minimum asserted (A9). Q5: probably our own Pico 2 firmware, by the
maintainer's recollection, unconfirmed; A3 landed regardless (#81). Q6: the
host ratchet is a hard gate at ±3 % (A8). Each question stays as asked,
with its decision beneath it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GKzq1VSdcMYCRMhddiMaf
tap pushed a commit that referenced this pull request Oct 12, 2026
Q1: the bench is USB asynchronous mode, with the Pico 2 as the
asynchronous-mode device on its own crystal (its USB audio firmware joins
A11), and it runs twice, so the timestamp servo is a 1.0 item (B3, C1, the
verdict rule). Q2: no outside consumer links a C ABI; MAJOR is the ABI;
FetchContent and add_subdirectory are the only supported consumption at
1.0 (C3). Q3: as landed in A1 (#80). Q4: DspTap tagged independently, the
minimum asserted (A9). Q5: probably our own Pico 2 firmware, by the
maintainer's recollection, unconfirmed; A3 landed regardless (#81). Q6: the
host ratchet is a hard gate at ±3 % (A8). Each question stays as asked,
with its decision beneath it.

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

2 participants