Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 71 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ on:
pull_request:
workflow_dispatch:

# Superseded-run cancellation: a new push to the same PR (or to main)
# cancels the previous in-flight run. Those cancelled checks attach to the
# old commit, so the current head stays clean.
permissions:
contents: read

# Superseded-run cancellation: a new push to the same PR cancels the
# previous in-flight run. Those cancelled checks attach to the old commit,
# so the current head stays clean. Never on main (every main commit keeps
# its ratchet evidence), and never on the monorepo migration branch, whose
# plan requires a complete run for every pushed commit.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.head_ref != 'claude/sample-rate-expansion-strategies-ezqzu6' }}

jobs:
build-test:
Expand All @@ -35,7 +40,7 @@ jobs:
- { os: macos-latest, name: macos, capi: ON }
- { os: windows-latest, name: windows, capi: OFF }
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

Expand All @@ -50,13 +55,13 @@ jobs:
run: cmake --build build --config Release

- name: Test
run: ctest --test-dir build --build-config Release --output-on-failure
run: ctest --test-dir build --build-config Release --output-on-failure --no-tests=error

sanitizers:
name: ASan + UBSan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

Expand All @@ -74,7 +79,7 @@ jobs:
run: cmake --build build -j 4

- name: Test
run: ctest --test-dir build --output-on-failure
run: ctest --test-dir build --output-on-failure --no-tests=error

# ------------------------------------------------------------------------
# Embedded matrix (PLAN.md section 7): the M33/M55 eurorack/pedal cores and
Expand All @@ -86,7 +91,7 @@ jobs:

hexagon-qemu:
name: Hexagon cross (QEMU)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
# Prebuilt open-source toolchain (BSD-3) published by Qualcomm/Quicinc;
Expand All @@ -97,13 +102,13 @@ jobs:
# published SHA256SUMS).
HEXAGON_TOOLCHAIN_SHA256: "55b41922318f6331590ab7baa7f5dbdd99c109327a9c44a52c5e9878fab148c1"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

- name: Cache toolchain
id: cache
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/hexagon
# Keyed on the pinned digest: every job that can write this key
Expand Down Expand Up @@ -152,25 +157,36 @@ jobs:
# -j 4: each test is an independent qemu-user process and the
# per-test soft-double table construction dominates, so the suite
# parallelizes cleanly (and serial would crowd the job timeout).
# -V --output-log keeps every test's own output ([ RUN ] lines,
# [ measured ] numbers), which --output-on-failure prints only for
# failures; the log is uploaded below as evidence.
run: >
ctest --test-dir build -j 4 --output-on-failure
ctest --test-dir build -j 4 --output-on-failure --no-tests=error
-V --output-log ctest-hexagon.log
-E 'BadProfilesThrow|LatencyAndValidation'
# This static-musl toolchain cannot unwind across frames — the
# constructor throws correctly but EXPECT_THROW never catches and
# libc++abi terminates (same known debt as SampleRateTap's leg).
# Validation is target-independent and covered on every other leg.

- name: Upload test log
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ctest-hexagon
path: ctest-hexagon.log

# Cross-compile for Arm Cortex-M55 (bare metal, newlib + semihosting) and
# run the emulation-sized test subset on QEMU's MPS3 AN547 board model.
# Validates the converter on a 32-bit MCU-class target with no OS, no
# threads and no double-precision FPU; the fixed-point datapaths are the
# performance-appropriate formats here.
cortex-m55-qemu:
name: Cortex-M55 cross (QEMU)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

Expand All @@ -190,18 +206,27 @@ jobs:
run: cmake --build build -j 4

- name: Test under emulation
run: ctest --test-dir build --output-on-failure
run: >
ctest --test-dir build --output-on-failure --no-tests=error
-V --output-log ctest-m55.log

- name: Upload test log
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ctest-m55
path: ctest-m55.log

# Cortex-M33 (Raspberry Pi Pico 2 / RP2350 class: single-precision FPU,
# no FP64, no MVE) on QEMU's MPS2+ AN505 model. Shares the Armv8-M
# startup with the M55 target; quantifies the soft-double design path and
# anchors the Q15/Q31 budgets for Pico-class parts.
cortex-m33-qemu:
name: Cortex-M33 cross (QEMU)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

Expand All @@ -221,7 +246,16 @@ jobs:
run: cmake --build build -j 4

- name: Test under emulation
run: ctest --test-dir build --output-on-failure
run: >
ctest --test-dir build --output-on-failure --no-tests=error
-V --output-log ctest-m33.log

- name: Upload test log
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ctest-m33
path: ctest-m33.log

# Deterministic instruction-count ratchet (PLAN.md section 7): fixed
# workloads under QEMU with a counting plugin, gated two-sided (±3%)
Expand All @@ -230,7 +264,9 @@ jobs:
# measurement harness every M7 lever must move before it merges.
icount-ratchet:
name: Instruction-count ratchet
runs-on: ubuntu-latest
# Pinned image: the counts are a function of the apt toolchain, the
# plugin build and QEMU, so the job must not straddle an image rollout.
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
# Commit the v8.2.2 tag pointed at when pinned (tags are movable;
Expand All @@ -244,7 +280,7 @@ jobs:
# shared toolchain cache, so it must verify against the same digest.
HEXAGON_TOOLCHAIN_SHA256: "55b41922318f6331590ab7baa7f5dbdd99c109327a9c44a52c5e9878fab148c1"
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive

Expand All @@ -264,6 +300,15 @@ jobs:
gcc -shared -fPIC $(pkg-config --cflags glib-2.0) -I/tmp \
-o /tmp/libinsncount.so tools/qemu_insn_plugin/insn_count.c

# What produced the counts: the runner image and the toolchain packages
# (the counts move when either does; PLAN.md section 7).
- name: Record image and toolchain versions
id: image
run: |
echo "image: ${ImageOS:-unknown} ${ImageVersion:-unknown}"
dpkg-query -W gcc-arm-none-eabi qemu-system-arm
echo "os=${ImageOS:-unknown}" >> "$GITHUB_OUTPUT"

# Release (-O2), matching how the baselines were recorded.
- name: Build M55 workloads
run: >
Expand Down Expand Up @@ -305,10 +350,13 @@ jobs:
- name: Cache plugin-enabled qemu-hexagon
if: ${{ !cancelled() }}
id: qemu-hex
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/qemu-hexagon-plugins
key: qemu-hexagon-plugins-${{ env.QEMU_SRC_URL }}-1
# The image OS is part of the key: the cached binary links the
# image's glib, so a new Ubuntu release must not restore an old
# build. (Weekly image updates keep the same glib ABI.)
key: qemu-hexagon-plugins-${{ steps.image.outputs.os }}-${{ env.QEMU_SRC_URL }}-1

- name: Build plugin-enabled qemu-hexagon
if: ${{ !cancelled() && steps.qemu-hex.outputs.cache-hit != 'true' }}
Expand All @@ -330,7 +378,7 @@ jobs:
- name: Cache Hexagon toolchain
if: ${{ !cancelled() }}
id: cache
uses: actions/cache@v4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/hexagon
# Same digest-keyed name as the hexagon-qemu job; the download
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ on:
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.head_ref != 'claude/sample-rate-expansion-strategies-ezqzu6' }}

jobs:
drift:
Expand All @@ -26,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: recursive
- name: Install tools
Expand Down
12 changes: 12 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,18 @@ executed (it measures the shipping C++, not a Python re-implementation).
SampleRateTap now does (steady state = 4 s − 2 s), would restore the
gate's sensitivity.

- **Hexagon harness isolation (monorepo migration step P.2; re-record,
not a lever).** `scripts/icount.py` now runs every Hexagon workload
from one fixed path with a fixed `argv[0]` and an empty environment:
qemu-hexagon (user mode) copies argv, the exec path and the host
environment onto the guest stack, and static musl's startup walks
them, so the build path and the runner's environment were part of
every Hexagon count. First isolated CI run: −3,724…−3,809 instructions
per scenario (−0.0004…−0.0136%), inside the gate; M33/M55 (system
mode, `main(0, NULL)`) unaffected and matching their baselines to the
instruction. Hexagon baselines re-recorded so exact (`--exact`)
comparisons start from the isolated harness.

v0.1 ships at M6. Nothing in M7+ blocks it. **v0.3 (2026-08-07): the
profile-ladder re-pin.** economy moved to the 18 kHz/58/38 design (the
"economy18" spec-relaxation experiment, measured through every leg: scipy
Expand Down
20 changes: 10 additions & 10 deletions bench/baselines.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"hexagon": {
"down_float_eco": 304640233,
"down_float_tr": 944369106,
"down_q15_eco": 45680933,
"down_q15_se": 33239067,
"down_q31_eco": 45626536,
"up_float_eco": 220218304,
"up_float_tr": 533814335,
"up_q15_eco": 35994330,
"up_q15_se": 27428012,
"up_q31_eco": 36011772
"down_float_eco": 304636424,
"down_float_tr": 944365314,
"down_q15_eco": 45677158,
"down_q15_se": 33235309,
"down_q31_eco": 45622761,
"up_float_eco": 220214529,
"up_float_tr": 533810577,
"up_q15_eco": 35990589,
"up_q15_se": 27424288,
"up_q31_eco": 36008031
},
"m33": {
"down_float_eco": 1720707553,
Expand Down
48 changes: 29 additions & 19 deletions notebooks/design_spike.ipynb

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions notebooks/figure_digest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Printed digests of every figure's plotted data, for A/B comparison.

A committed notebook's figures are PNGs, which a text comparison cannot
see and which are not byte-stable across matplotlib versions. So that a
changed curve still shows up as a changed *text* output, install() wraps
plt.show(): before each figure renders, it prints one line

[ figure ] digest <16 hex digits> (<n> arrays)

hashing the data drawn on the figure (line data, collection offsets and
values, images, bar geometry), each array quantized to 9 significant
digits of its largest magnitude so last-bit floating-point noise does not
register. The digest is only meaningful against another run in the same
environment (requirements.lock pins it): the monorepo migration's notebook
gate re-executes a reference and a candidate tree in one job and compares
these lines.
"""
import hashlib

import matplotlib.pyplot as plt
import numpy as np


def _rounded(values) -> np.ndarray:
# Quantize to 9 significant digits of the array's largest magnitude, so
# noise far below the plotted scale (including tiny values next to an
# exact zero) does not change the digest. Non-finite values pass through.
a = np.asarray(np.ma.filled(np.ma.asarray(values, dtype=float), np.nan), dtype=float).ravel()
finite = np.isfinite(a)
out = a.copy()
peak = np.max(np.abs(a[finite])) if finite.any() else 0.0
if peak > 0.0:
step = 10.0 ** (np.floor(np.log10(peak)) - 8)
out[finite] = np.round(a[finite] / step) * step + 0.0 # + 0.0 folds -0.0
return out


def _arrays(fig):
for ax in fig.axes:
for line in ax.get_lines():
yield line.get_xydata()
for coll in ax.collections:
offsets = coll.get_offsets()
if len(offsets):
yield offsets
values = coll.get_array()
if values is not None:
yield values
for image in ax.get_images():
yield image.get_array()
for patch in ax.patches:
yield np.asarray(patch.get_bbox().bounds)


def digest(fig):
"""Return (hex digest, number of arrays) for one figure."""
h, n = hashlib.blake2b(digest_size=8), 0
for values in _arrays(fig):
h.update(_rounded(values).tobytes())
n += 1
return h.hexdigest(), n


def install():
"""Make plt.show() print a digest line for every open figure first."""
if getattr(plt.show, "_figure_digest", False):
return
original = plt.show

def show(*args, **kwargs):
for num in plt.get_fignums():
hexdigest, n = digest(plt.figure(num))
print(f"[ figure ] digest {hexdigest} ({n} arrays)")
return original(*args, **kwargs)

show._figure_digest = True
plt.show = show
Loading
Loading