From b19b1ab218848f9a8e9179c51b02650b322952b2 Mon Sep 17 00:00:00 2001 From: w4ffl35 <25737761+w4ffl35@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:54:36 -0600 Subject: [PATCH 1/4] Add the model-hub verification sandbox runner (issue #48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GitHub Actions workflow, triggered by repository_dispatch from spikeforge-hub-api, that fetches one community-uploaded artifact from a signed URL and runs it through the existing verification pipeline on a GitHub-hosted (never self-hosted) runner: bundle checksum/manifest verification and the weights_only=True load already used by bundle.py, spikeforge_hub.inspect and .compat, the NIR export and reference- interpreter drift check spikeforge_hub.import_model already runs for the curated catalog, and a spikeforge_targets.energy report. It posts a signed pass/fail report to the hub API's (not yet built) POST /internal/v1/verifications/{version_id}, naming which step failed and why on rejection rather than a generic failure message. Also adds a decompressed-size cap to bundle.py's archive reader, which had none: a zip bomb was previously an unbounded read into memory rather than a clean, named rejection. The self-hosted spikeforge-ci runner (deploy-hetzner.yml) is deliberately never used here, since it holds the Hetzner deploy key and running torch.load on a stranger's bytes there is the plan's own worst-case scenario (plans/hub_accounts_plan.md §7.2). --- .github/workflows/hub-verify.yml | 112 ++++++++++++++++++++++++ pytest.ini | 2 +- scripts/hub_verify/__init__.py | 27 ++++++ scripts/hub_verify/callback.py | 56 ++++++++++++ scripts/hub_verify/cli.py | 117 +++++++++++++++++++++++++ scripts/hub_verify/energy.py | 29 +++++++ scripts/hub_verify/errors.py | 30 +++++++ scripts/hub_verify/fetch.py | 59 +++++++++++++ scripts/hub_verify/fixtures.py | 22 +++++ scripts/hub_verify/funnel.py | 69 +++++++++++++++ scripts/hub_verify/pipeline.py | 34 ++++++++ scripts/hub_verify/report.py | 57 ++++++++++++ scripts/hub_verify/signing.py | 39 +++++++++ spikeforge/serving/bundle.py | 19 ++++ spikeforge/serving/bundle_manifest.py | 9 ++ tests/test_hub_verify_callback.py | 91 ++++++++++++++++++++ tests/test_hub_verify_cli.py | 119 ++++++++++++++++++++++++++ tests/test_hub_verify_fetch.py | 84 ++++++++++++++++++ tests/test_hub_verify_pipeline.py | 74 ++++++++++++++++ tests/test_hub_verify_report.py | 44 ++++++++++ tests/test_hub_verify_signing.py | 33 +++++++ tests/test_serving_bundle.py | 17 ++++ 22 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/hub-verify.yml create mode 100644 scripts/hub_verify/__init__.py create mode 100644 scripts/hub_verify/callback.py create mode 100644 scripts/hub_verify/cli.py create mode 100644 scripts/hub_verify/energy.py create mode 100644 scripts/hub_verify/errors.py create mode 100644 scripts/hub_verify/fetch.py create mode 100644 scripts/hub_verify/fixtures.py create mode 100644 scripts/hub_verify/funnel.py create mode 100644 scripts/hub_verify/pipeline.py create mode 100644 scripts/hub_verify/report.py create mode 100644 scripts/hub_verify/signing.py create mode 100644 tests/test_hub_verify_callback.py create mode 100644 tests/test_hub_verify_cli.py create mode 100644 tests/test_hub_verify_fetch.py create mode 100644 tests/test_hub_verify_pipeline.py create mode 100644 tests/test_hub_verify_report.py create mode 100644 tests/test_hub_verify_signing.py diff --git a/.github/workflows/hub-verify.yml b/.github/workflows/hub-verify.yml new file mode 100644 index 0000000..5f401a9 --- /dev/null +++ b/.github/workflows/hub-verify.yml @@ -0,0 +1,112 @@ +name: Hub artifact verification (sandbox) + +# The community-upload verification sandbox (issue #48; the design of +# record is plans/hub_accounts_plan.md §7). Dispatched by +# spikeforge-hub-api once a committed upload enters its `verifying` +# state. That hub-api endpoint (spikeforge-hub-api#4) is not built as of +# this writing, so the `hub-artifact-verify` event type and the +# client_payload shape below are this repo's half of a contract the two +# repositories have to agree on -- see the pull request that introduced +# this file for the coordination note. +# +# SECURITY -- read before changing `runs-on` or the egress allowlist: +# +# This job runs `torch.load` on bytes a stranger uploaded. It MUST stay +# on a GitHub-hosted, ephemeral runner. It must NEVER run on the +# self-hosted `spikeforge-ci` runner that `deploy-hetzner.yml` uses -- +# that runner holds the Hetzner deploy key, and running untrusted-content +# code on a persistent self-hosted runner is named in the plan (§7.2) as +# the single most predictable way this whole design could be +# compromised. This is deliberately the first workflow in this repository +# that does not use `spikeforge-ci`. +on: + repository_dispatch: + types: [hub-artifact-verify] + +permissions: + contents: read + +# One verification at a time per version; a second dispatch for the same +# version (a retry) should not race the first, but must not cancel it +# silently either -- a cancelled run still owes the hub API a report, or +# the version is stuck in `verifying` forever (the exact bug +# spikeforge-hub-api#4 exists to fix). +concurrency: + group: hub-verify-${{ github.event.client_payload.version_id }} + cancel-in-progress: false + +env: + # CPU wheels keep this light; mirrors ci.yml's TORCH_INDEX_URL. + TORCH_INDEX_URL: https://download.pytorch.org/whl/cpu + +jobs: + verify: + # GitHub-hosted only -- see the header comment. Do not add + # `spikeforge-ci` or any other self-hosted label to this job. + runs-on: ubuntu-latest + # A wall-clock ceiling independent of any one step's own timeout, per + # plans/hub_accounts_plan.md §7.2 ("a wall-clock timeout"). + timeout-minutes: 20 + steps: + # Restricts this job's DNS/network egress to exactly what it needs: + # GitHub's own checkout/runner endpoints, PyPI + the CPU wheel + # index for installing dependencies, and the two hub-api endpoints + # this job talks to (the artifact CDN and the verification + # callback) -- plans/hub_accounts_plan.md §7.2's "no network egress + # beyond the two endpoints this job actually needs", extended by + # the toolchain-setup endpoints every job on this runner requires + # regardless. If this allowlist ever needs to change, flip + # `egress-policy` to `audit` first, read the resulting job summary + # for what was actually contacted, then return it to `block`. + - name: Harden the runner's network egress + uses: step-security/harden-runner@v2 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + api.github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + results-receiver.actions.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + download.pytorch.org:443 + hub.spikeforge.net:443 + cdn.spikeforge.net:443 + + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # No `cache: pip` here, deliberately: this job's whole point is + # to be a disposable sandbox for untrusted content, and a pip + # cache is one more thing that could be poisoned across runs. + + - name: Install torch CPU wheels + run: | + python -m pip install --upgrade pip + pip install torch torchvision \ + --index-url ${{ env.TORCH_INDEX_URL }} + + - name: Install core + targets + hub editable + run: | + pip install -e "./packages/spikeforge[dev,nir]" \ + -e ./packages/spikeforge-targets \ + -e ./packages/spikeforge-hub + + - name: Run the verification pipeline and post the signed report + env: + PYTHONPATH: ${{ github.workspace }}/scripts + SPIKEFORGE_HUB_VERIFY_VERSION_ID: >- + ${{ github.event.client_payload.version_id }} + SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL: >- + ${{ github.event.client_payload.artifact_url }} + # Fixed to this repository's own configuration -- never read + # from the dispatch payload. The callback destination must not + # be data a compromised or malformed dispatch could redirect; + # see hub_verify/cli.py's module docstring. + SPIKEFORGE_HUB_API_BASE_URL: ${{ vars.SPIKEFORGE_HUB_API_BASE_URL }} + SPIKEFORGE_HUB_VERIFICATION_SECRET: >- + ${{ secrets.SPIKEFORGE_HUB_VERIFICATION_SECRET }} + run: python -m hub_verify.cli diff --git a/pytest.ini b/pytest.ini index 7ae7e0c..cbe04ac 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] testpaths = tests -pythonpath = . +pythonpath = . scripts addopts = --cov=spikeforge --cov-report=term-missing markers = network: fetches a dataset, so it reaches the network on a cold diff --git a/scripts/hub_verify/__init__.py b/scripts/hub_verify/__init__.py new file mode 100644 index 0000000..2fa2202 --- /dev/null +++ b/scripts/hub_verify/__init__.py @@ -0,0 +1,27 @@ +"""The community-upload verification sandbox runner (issue #48, P4). + +Invoked by ``.github/workflows/hub-verify.yml`` on a ``repository_dispatch`` +from ``spikeforge-hub-api``, on a GitHub-hosted (never self-hosted) runner: +the self-hosted ``spikeforge-ci`` runner holds the Hetzner deploy key, and +running ``torch.load`` on a stranger's bytes there is the single most +predictable way this design could be compromised +(``plans/hub_accounts_plan.md`` §7.2). + +This package is CI scaffolding, not a distributed package: nothing under +``spikeforge*`` imports it, and it ships in no wheel. It reuses the existing, +already-shipped pipeline rather than reimplementing any of it: + +* :mod:`spikeforge.serving.bundle` -- checksum/manifest verification and the + ``weights_only=True`` load already used for every bundle. +* :mod:`spikeforge_hub.inspect` / :mod:`spikeforge_hub.compat` -- the same + structural inspection and preset classification the curated catalog uses. +* :mod:`spikeforge.nir_bridge` -- the same NIR export and independent + reference-interpreter drift check invariant 4 of ``rules.md`` requires. +* :mod:`spikeforge_targets.energy` -- the same SOP/MAC/AC accounting used + elsewhere in the project. + +See :mod:`hub_verify.cli` for the orchestration entry point and +:mod:`hub_verify.report` for the report/callback contract this runner +implements against ``spikeforge-hub-api``'s (not yet built, as of this +writing) ``POST /internal/v1/verifications/{version_id}``. +""" diff --git a/scripts/hub_verify/callback.py b/scripts/hub_verify/callback.py new file mode 100644 index 0000000..423e506 --- /dev/null +++ b/scripts/hub_verify/callback.py @@ -0,0 +1,56 @@ +"""Post the signed verification report back to the hub API. + +Follows the same ``urllib``-only request pattern already used in this +monorepo (:class:`spikeforge_clients.transport.HttpTransport`) rather than +adding a new HTTP dependency: this script installs into a throwaway CI +job, but the project's stated convention is stdlib-only HTTP regardless. +""" + +import json +import urllib.error +import urllib.request +from typing import Final + +from hub_verify.errors import CallbackError +from hub_verify.signing import SIGNATURE_HEADER, sign_body + +_TIMEOUT_SECONDS: Final[int] = 30 + + +def _url(base_url: str, version_id: str) -> str: + """Return the internal verification-callback URL for ``version_id``.""" + return f"{base_url.rstrip('/')}/internal/v1/verifications/{version_id}" + + +def post_report( + base_url: str, version_id: str, report: dict, secret: str +) -> None: + """POST ``report`` for ``version_id``, signed with ``secret``. + + Raises :class:`CallbackError` on any failure to deliver it -- unlike an + artifact rejection, a delivery failure is not something this job can + itself report to the hub, so it must fail the job loudly instead of + silently leaving the version stuck in ``verifying``. + """ + body = json.dumps(report, sort_keys=True).encode("utf-8") + request = urllib.request.Request( + _url(base_url, version_id), + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + SIGNATURE_HEADER: sign_body(body, secret), + }, + ) + try: + with urllib.request.urlopen( + request, timeout=_TIMEOUT_SECONDS + ) as reply: + if reply.status >= 400: + raise CallbackError(f"hub API replied {reply.status}") + except urllib.error.HTTPError as error: + raise CallbackError( + f"hub API replied {error.code}: {error.read()!r}" + ) from error + except (urllib.error.URLError, OSError) as error: + raise CallbackError(f"callback delivery failed: {error}") from error diff --git a/scripts/hub_verify/cli.py b/scripts/hub_verify/cli.py new file mode 100644 index 0000000..caf0101 --- /dev/null +++ b/scripts/hub_verify/cli.py @@ -0,0 +1,117 @@ +"""Fetch, verify, report, and post one dispatched artifact. + +Invoked by ``.github/workflows/hub-verify.yml`` as +``python -m hub_verify.cli``. Every input is an environment variable the +workflow sets from the ``repository_dispatch`` payload and this +repository's own secrets/variables -- see the workflow file for exactly +which ones, and for why the callback base URL is a repository-configured +constant rather than something read out of the dispatch payload (a fixed +destination this job trusts, not attacker-influenced data). +""" + +import os +import sys +import tempfile +from typing import Dict, List, Tuple + +from hub_verify.callback import post_report +from hub_verify.energy import energy_reports +from hub_verify.errors import CallbackError, VerificationStepError +from hub_verify.fetch import fetch_artifact +from hub_verify.funnel import run_funnel +from hub_verify.pipeline import load_and_build +from hub_verify.report import build_report, rejection_summary + +_EMPTY_CHECKS: Dict[str, object] = { + "bundle": None, + "inspect": None, + "compat": None, + "drift": None, + "energy": None, +} + + +def _env(name: str) -> str: + """Return the required environment variable ``name`` or exit loudly.""" + value = os.environ.get(name, "") + if not value: + sys.exit(f"hub_verify: missing required env var {name}") + return value + + +def _run_url() -> str: + """Return this job's run URL for the report, or an empty string.""" + server = os.environ.get("GITHUB_SERVER_URL", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + if not (server and repo and run_id): + return "" + return f"{server}/{repo}/actions/runs/{run_id}" + + +def _run_checks( + tmp_dir: str, artifact_path: str +) -> Tuple[Dict[str, object], List[str]]: + """Run the bundle/funnel/energy stages; return ``(checks, reasons)``. + + A stage that raises :class:`VerificationStepError` stops the pipeline + there -- a later stage needs the module the failed stage would have + produced -- and its message becomes the sole rejection reason. + """ + checks = dict(_EMPTY_CHECKS) + try: + bundle, module = load_and_build(artifact_path) + checks["bundle"] = { + "ok": True, "topology": bundle.manifest.get("topology") + } + funnel = run_funnel(bundle, module, tmp_dir) + checks["inspect"] = funnel["inspect"] + checks["compat"] = funnel["compat"] + checks["drift"] = funnel["drift"] + checks["energy"] = energy_reports(bundle.spec, module) + except VerificationStepError as error: + checks[error.step] = {"ok": False, "detail": error.reason} + return checks, [f"{error.step}: {error.reason}"] + return checks, [] + + +def _verify(artifact_url: str) -> Tuple[Dict[str, object], List[str]]: + """Fetch the artifact and run every check against it.""" + with tempfile.TemporaryDirectory() as tmp_dir: + try: + artifact_path = fetch_artifact( + artifact_url, os.path.join(tmp_dir, "artifact.spkf") + ) + except VerificationStepError as error: + return dict(_EMPTY_CHECKS), [f"{error.step}: {error.reason}"] + return _run_checks(tmp_dir, artifact_path) + + +def main() -> int: + """Verify the dispatched artifact and post its signed report.""" + version_id = _env("SPIKEFORGE_HUB_VERIFY_VERSION_ID") + artifact_url = _env("SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL") + base_url = _env("SPIKEFORGE_HUB_API_BASE_URL") + secret = _env("SPIKEFORGE_HUB_VERIFICATION_SECRET") + + checks, reasons = _verify(artifact_url) + passed = not reasons + summary = "passed every check" if passed else rejection_summary(reasons) + report = build_report( + version_id=version_id, + passed=passed, + reasons=reasons, + checks=checks, + summary=summary, + run_url=_run_url(), + ) + try: + post_report(base_url, version_id, report, secret) + except CallbackError as error: + sys.exit(f"hub_verify: could not deliver the report: {error}") + print(summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/hub_verify/energy.py b/scripts/hub_verify/energy.py new file mode 100644 index 0000000..f8590d1 --- /dev/null +++ b/scripts/hub_verify/energy.py @@ -0,0 +1,29 @@ +"""Produce the SOP/MAC/AC energy/latency report for every known target. + +Reuses :func:`spikeforge_targets.energy.accounting.account_spikes` exactly +as elsewhere in the project; a target whose SDK is not installed on this +runner reports ``basis: "unavailable"`` rather than a fabricated number -- +the same honest-degradation convention ``ci.yml``'s ``test-deploy`` job +checks for the curated catalog. Energy is descriptive here, never a pass +gate: an artifact is not rejected for costing more than some threshold. +""" + +from typing import Any, Dict, List + +from hub_verify.fixtures import probe_spikes +from spikeforge.topology.spec import TopologySpec +from spikeforge.topology.stage_module import StageModule +from spikeforge_targets import registry +from spikeforge_targets.energy.accounting import account_spikes + + +def energy_reports( + spec: TopologySpec, module: StageModule +) -> List[Dict[str, Any]]: + """Return one energy/latency report per registered target.""" + spikes = probe_spikes(spec) + reports = [] + for name in registry.target_names(): + _, report = account_spikes(module, spikes, name) + reports.append(report.to_dict()) + return reports diff --git a/scripts/hub_verify/errors.py b/scripts/hub_verify/errors.py new file mode 100644 index 0000000..304a9f0 --- /dev/null +++ b/scripts/hub_verify/errors.py @@ -0,0 +1,30 @@ +"""Typed errors naming which verification step failed, and why.""" + + +class VerificationStepError(Exception): + """Raised by one pipeline stage with an actionable, named reason. + + ``step`` identifies which stage of the pipeline rejected the artifact + (e.g. ``"fetch"``, ``"bundle"``, ``"drift"``) and ``reason`` is the + human-readable detail shown to the uploader -- "weights are not + loadable" rather than "verification failed" (the honesty bar + ``docs/model-hub.md`` states for every rejection in this project). + """ + + def __init__(self, step: str, reason: str) -> None: + """Record ``step`` and ``reason`` and build a clear message.""" + super().__init__(f"{step}: {reason}") + self.step: str = step + self.reason: str = reason + + +class FetchError(VerificationStepError): + """Raised when the artifact cannot be fetched from its signed URL.""" + + def __init__(self, reason: str) -> None: + """Record the fetch failure's ``reason``.""" + super().__init__("fetch", reason) + + +class CallbackError(Exception): + """Raised when the signed report cannot be posted back to the hub.""" diff --git a/scripts/hub_verify/fetch.py b/scripts/hub_verify/fetch.py new file mode 100644 index 0000000..ce846e0 --- /dev/null +++ b/scripts/hub_verify/fetch.py @@ -0,0 +1,59 @@ +"""Fetch the one artifact this job was dispatched to verify. + +The URL is a signed, read-only, single-object URL the hub API mints for +this job alone (``plans/hub_accounts_plan.md`` §7.2); this module does not +authenticate to anything else and never reuses the URL beyond one GET. +""" + +import urllib.error +import urllib.request +from typing import Any, Final + +from hub_verify.errors import FetchError + +#: Matches the community-upload single-artifact cap +#: (``plans/hub_accounts_plan.md`` §5.3). A ``Content-Length`` claim is +#: never trusted alone -- the stream itself is cut off past this many bytes. +MAX_ARTIFACT_BYTES: Final[int] = 256 * 1024 * 1024 + +#: Refuse to hang on a stalled or hostile server. +_TIMEOUT_SECONDS = 60 +_CHUNK_SIZE = 1 << 20 + + +def _read_capped(response: Any, dest: str) -> int: + """Stream ``response`` into ``dest``, raising past the byte cap.""" + written = 0 + with open(dest, "wb") as handle: + while True: + chunk = response.read(_CHUNK_SIZE) + if not chunk: + return written + written += len(chunk) + if written > MAX_ARTIFACT_BYTES: + raise FetchError( + f"artifact exceeds the {MAX_ARTIFACT_BYTES} byte cap" + ) + handle.write(chunk) + + +def fetch_artifact(url: str, dest: str) -> str: + """Download ``url`` to ``dest`` and return ``dest``. + + Raises :class:`FetchError` naming the reason on any network failure or + a payload past :data:`MAX_ARTIFACT_BYTES`, so a hostile or broken + signed URL is reported like any other named rejection rather than an + unhandled traceback. + """ + try: + with urllib.request.urlopen( + url, timeout=_TIMEOUT_SECONDS + ) as response: + _read_capped(response, dest) + except FetchError: + raise + except urllib.error.HTTPError as error: + raise FetchError(f"HTTP {error.code} fetching artifact") from error + except (urllib.error.URLError, OSError) as error: + raise FetchError(f"network fetch failed: {error}") from error + return dest diff --git a/scripts/hub_verify/fixtures.py b/scripts/hub_verify/fixtures.py new file mode 100644 index 0000000..e586a55 --- /dev/null +++ b/scripts/hub_verify/fixtures.py @@ -0,0 +1,22 @@ +"""The one deterministic spike probe shared by the drift and energy checks. + +Both checks need some input to run the module over; using the same seeded, +low-density probe for both means one fixture to reason about instead of +two, and matches the fixture :mod:`spikeforge_targets.energy.accounting` +already uses for its own topology fixtures. +""" + +import torch + +from spikeforge.topology.spec import TopologySpec +from spikeforge_targets.event_runtime.spike_view import synthetic_spikes + +#: Matches ``spikeforge_targets/energy/accounting.py``'s own defaults. +STEPS = 8 +BATCH = 2 +SEED = 0 + + +def probe_spikes(spec: TopologySpec) -> torch.Tensor: + """Return a seeded, low-density spike probe shaped for ``spec``.""" + return synthetic_spikes(spec, STEPS, BATCH, SEED) diff --git a/scripts/hub_verify/funnel.py b/scripts/hub_verify/funnel.py new file mode 100644 index 0000000..5c9a633 --- /dev/null +++ b/scripts/hub_verify/funnel.py @@ -0,0 +1,69 @@ +"""Run the inspect -> compat -> NIR-export-and-drift funnel on a bundle. + +Reuses :mod:`spikeforge_hub.inspect`, :mod:`spikeforge_hub.compat`, and +:func:`spikeforge.nir_bridge.validate` exactly as +:mod:`spikeforge_hub.import_model` runs them for the curated catalog +(``rules.md`` invariant 4: validation stays independent of the module it +checks, so a drift check is a genuine cross-check). A bundle already +carries its own explicit :class:`~spikeforge.topology.spec.TopologySpec`, +so unlike a bare downloaded artifact this funnel never has to guess a +topology before it can build a module: ``compat`` here is informational +(does this map onto a *known, registered* preset?), not a gate -- the +drift check always runs against the bundle's own declared architecture. +""" + +import os +from typing import Any, Dict + +import torch + +from hub_verify.errors import VerificationStepError +from hub_verify.fixtures import probe_spikes +from spikeforge.nir_bridge import validate +from spikeforge.serving.bundle import DeploymentBundle +from spikeforge_hub.compat import classify +from spikeforge_hub.inspect import inspect_artifact + + +def _write_weights(bundle: DeploymentBundle, tmp_dir: str) -> str: + """Write the bundle's already-verified weights to a bare ``.pt`` file. + + The bytes have already passed a ``weights_only=True`` load in + :func:`hub_verify.pipeline.load_and_build`; re-loading the identical + bytes through :mod:`spikeforge_hub.inspect`'s bare-torch reader is + safe, because that reload can only reconstruct the same objects + already proven benign by the stricter load. + """ + path = os.path.join(tmp_dir, "weights.pt") + torch.save(dict(bundle.weights), path) + return path + + +def _drift_check(bundle: DeploymentBundle, module: Any) -> Dict[str, Any]: + """Run the reference-interpreter drift check, raising on drift.""" + spikes = probe_spikes(bundle.spec) + result = validate(bundle.spec, module, spikes) + if not result["within_tolerance"]: + worst = result.get("worst") or {} + where = worst.get("layer", "unknown layer") + what = worst.get("quantity", "unknown quantity") + raise VerificationStepError( + "drift", + "the exported NIR graph drifts from the trained module " + f"at {where} ({what})", + ) + return dict(result) + + +def run_funnel( + bundle: DeploymentBundle, module: Any, tmp_dir: str +) -> Dict[str, Any]: + """Return the inspect/compat/drift block of the verification report.""" + weights_path = _write_weights(bundle, tmp_dir) + report = inspect_artifact(weights_path) + verdict = classify(report, bundle.manifest.get("topology")) + return { + "inspect": {"kind": report.kind}, + "compat": verdict.to_dict(), + "drift": _drift_check(bundle, module), + } diff --git a/scripts/hub_verify/pipeline.py b/scripts/hub_verify/pipeline.py new file mode 100644 index 0000000..129e1a6 --- /dev/null +++ b/scripts/hub_verify/pipeline.py @@ -0,0 +1,34 @@ +"""Load and build the ``.spkf`` bundle under verification. + +Wraps :class:`spikeforge.serving.bundle.DeploymentBundle` so a load or +build failure becomes a named +:class:`~hub_verify.errors.VerificationStepError` instead of an unhandled +traceback. The checksum/manifest verification and the +``weights_only=True`` weight load are exactly the ones ``bundle.py`` +already performs for every bundle load (non-negotiable for untrusted +content, per ``plans/hub_accounts_plan.md`` §7.2) -- nothing here +reimplements them. +""" + +from typing import Any, Tuple + +from hub_verify.errors import VerificationStepError +from spikeforge.serving.bundle import DeploymentBundle +from spikeforge.serving.errors import BundleError + + +def load_and_build(path: str) -> Tuple[DeploymentBundle, Any]: + """Return ``(bundle, module)`` for the ``.spkf`` archive at ``path``. + + :meth:`DeploymentBundle.load` runs the checksum/manifest verification + and the ``weights_only=True`` weight load; :meth:`build_module` then + strictly loads those weights into the module the manifest's own spec + describes -- a second, independent proof the weights actually fit the + declared architecture, not just that they deserialize. + """ + try: + bundle = DeploymentBundle.load(path, strict=True) + module = bundle.build_module() + except BundleError as error: + raise VerificationStepError("bundle", error.detail) from error + return bundle, module diff --git a/scripts/hub_verify/report.py b/scripts/hub_verify/report.py new file mode 100644 index 0000000..58a64e9 --- /dev/null +++ b/scripts/hub_verify/report.py @@ -0,0 +1,57 @@ +"""Assemble the verification report posted to the hub API. + +The one binding field is ``passed``: ``spikeforge-hub-api``'s +``hub_api/catalog/trust.py::label_for`` reads +``version.verification["passed"]`` and shows the ``machine-checked`` trust +label only when it is the JSON boolean ``true``. Everything else in this +report (``reasons``, ``checks``, ``summary``) is this runner's proposed +shape for the rest of the contract issue #48 and hub-api issue #4 both +describe in prose ("pass/fail, reasons, NIR/compat/energy summary") but +neither repository had committed code for as of this writing -- see the +pull request description for the coordination note. +""" + +import datetime +from typing import Any, Dict, List, Optional + + +def _now_iso() -> str: + """Return the current UTC time as an ISO-8601 string.""" + return ( + datetime.datetime.now(datetime.timezone.utc) + .isoformat(timespec="seconds") + ) + + +def build_report( + *, + version_id: str, + passed: bool, + reasons: List[str], + checks: Dict[str, Optional[Any]], + summary: str, + run_url: str, +) -> Dict[str, Any]: + """Return the JSON-able report body for one verification run. + + ``reasons`` names every failing step in the uploader's own words (see + :mod:`hub_verify.errors`); it is empty exactly when ``passed`` is True. + ``checks`` carries the bundle/inspect/compat/drift/energy detail a + listing page can render even for a passing artifact. + """ + return { + "version_id": version_id, + "passed": bool(passed), + "reasons": list(reasons), + "checks": checks, + "summary": summary, + "generated_at": _now_iso(), + "runner": {"workflow": "hub-verify.yml", "run_url": run_url}, + } + + +def rejection_summary(reasons: List[str]) -> str: + """Return a one-line summary naming the first rejection reason.""" + if not reasons: + return "rejected with no named reason (this is itself a bug)" + return f"rejected: {reasons[0]}" diff --git a/scripts/hub_verify/signing.py b/scripts/hub_verify/signing.py new file mode 100644 index 0000000..8677c89 --- /dev/null +++ b/scripts/hub_verify/signing.py @@ -0,0 +1,39 @@ +"""Sign the outgoing verification report for the hub API callback. + +Mirrors the HMAC-SHA256 pattern already used in this repository for a +signed record (:func:`spikeforge_hub.registry.sign_entry`), adapted for an +HTTP callback: the signature covers the exact request-body bytes, the same +convention GitHub itself uses for webhook signatures +(``X-Hub-Signature-256``), so the receiving side only needs to hash the raw +body it read -- no canonical re-serialization step to keep in sync across +two repositories. +""" + +import hashlib +import hmac + +#: The callback header carrying the signature, as ``sha256=``. +SIGNATURE_HEADER = "X-Spikeforge-Hub-Signature" + + +def _as_key(secret: str) -> bytes: + """Return ``secret`` as bytes, refusing an empty one.""" + if not secret: + raise ValueError("verification secret must not be empty") + return secret.encode("utf-8") + + +def sign_body(body: bytes, secret: str) -> str: + """Return the ``sha256=`` signature of ``body`` under ``secret``.""" + digest = hmac.new(_as_key(secret), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def signature_matches(body: bytes, secret: str, header_value: str) -> bool: + """Return True when ``header_value`` is ``body``'s signature. + + Provided for the hub-api side (or a test standing in for it) to verify + a callback with the same constant-time comparison this repository uses + elsewhere for signed records. + """ + return hmac.compare_digest(sign_body(body, secret), header_value) diff --git a/spikeforge/serving/bundle.py b/spikeforge/serving/bundle.py index 1d50d1f..8cbb64a 100644 --- a/spikeforge/serving/bundle.py +++ b/spikeforge/serving/bundle.py @@ -185,6 +185,24 @@ def _meta_input_size(meta: Mapping[str, Any]) -> Optional[Tuple[int, int]]: return None +def _check_decompressed_size( + path: str, archive: zipfile.ZipFile, names: Any +) -> None: + """Raise when ``names``' total decompressed size exceeds the cap. + + Checked against each entry's recorded ``file_size`` before any entry is + decompressed, so a small compressed payload declaring an enormous + decompressed size (a zip bomb) is refused rather than read into memory. + """ + total = sum(archive.getinfo(name).file_size for name in names) + if total > bm.MAX_DECOMPRESSED_BYTES: + raise BundleFormatError( + path, + f"decompressed size {total} exceeds the " + f"{bm.MAX_DECOMPRESSED_BYTES} byte cap", + ) + + def _read_archive(path: str) -> Dict[str, bytes]: """Return every entry of the zip at ``path``, or raise a typed error.""" if not os.path.exists(path): @@ -197,6 +215,7 @@ def _read_archive(path: str) -> Dict[str, bytes]: raise BundleFormatError( path, f"missing required entries: {missing}" ) + _check_decompressed_size(path, archive, names) return {name: archive.read(name) for name in names} except zipfile.BadZipFile as error: raise BundleFormatError( diff --git a/spikeforge/serving/bundle_manifest.py b/spikeforge/serving/bundle_manifest.py index 4c4e169..e3a5675 100644 --- a/spikeforge/serving/bundle_manifest.py +++ b/spikeforge/serving/bundle_manifest.py @@ -39,6 +39,15 @@ #: Entries a bundle may additionally carry. OPTIONAL_ENTRIES = (GRAPH_NAME,) +#: Refuse a bundle whose entries decompress past this many bytes in total. +#: Checked against each entry's recorded ``file_size`` before any entry is +#: read, so a small compressed payload declaring an enormous decompressed +#: size (a zip bomb) is rejected rather than read into memory. Matches the +#: community-upload single-artifact cap in +#: ``plans/hub_accounts_plan.md`` §5.3; real artifacts today are +#: 46 KB-414 KB (§1), so this is generous headroom, not a tight fit. +MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024 # 256 MiB + def checksum(payload: bytes) -> str: """Return the lowercase hex SHA-256 of ``payload``.""" diff --git a/tests/test_hub_verify_callback.py b/tests/test_hub_verify_callback.py new file mode 100644 index 0000000..894a16a --- /dev/null +++ b/tests/test_hub_verify_callback.py @@ -0,0 +1,91 @@ +"""Posting the signed report back to the hub API.""" + +import json +from typing import Any +from urllib import error as urlerror + +import pytest +from hub_verify import callback +from hub_verify.errors import CallbackError +from hub_verify.signing import SIGNATURE_HEADER, signature_matches + + +class _FakeReply: + """A minimal stand-in for the ``urlopen`` context manager.""" + + def __init__(self, status: int) -> None: + """Record the reply's HTTP ``status``.""" + self.status = status + + def __enter__(self) -> "_FakeReply": + """Support ``with urlopen(...) as reply``.""" + return self + + def __exit__(self, *exc_info: Any) -> None: + """Nothing to release for a fake reply.""" + + +def test_posts_a_correctly_signed_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The delivered request body is signed under the given secret.""" + sent = {} + + def _urlopen(request: Any, timeout: float) -> _FakeReply: + sent["url"] = request.full_url + sent["body"] = request.data + # ``Request.add_header`` stores every key through ``.capitalize()`` + # (first character up, the rest down), so the header must be read + # back the same way rather than by its original spelling. + sent["signature"] = request.headers[SIGNATURE_HEADER.capitalize()] + return _FakeReply(200) + + monkeypatch.setattr(callback.urllib.request, "urlopen", _urlopen) + report = {"version_id": "v1", "passed": True} + callback.post_report( + "https://hub.spikeforge.net", "v1", report, "sekrit" + ) + assert sent["url"] == ( + "https://hub.spikeforge.net/internal/v1/verifications/v1" + ) + assert json.loads(sent["body"]) == report + assert signature_matches(sent["body"], "sekrit", sent["signature"]) + + +def test_error_status_is_a_callback_error( + monkeypatch: pytest.MonkeyPatch +) -> None: + """A 4xx/5xx reply is reported by name, not swallowed.""" + monkeypatch.setattr( + callback.urllib.request, "urlopen", lambda *a, **k: _FakeReply(500) + ) + with pytest.raises(CallbackError, match="500"): + callback.post_report("https://hub.spikeforge.net", "v1", {}, "s") + + +def test_http_error_is_a_callback_error( + monkeypatch: pytest.MonkeyPatch +) -> None: + """An HTTPError from urlopen is wrapped, not left to propagate raw.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.HTTPError( + "url", 404, "not found", {}, __import__("io").BytesIO(b"nope") + ) + + monkeypatch.setattr(callback.urllib.request, "urlopen", _raise) + with pytest.raises(CallbackError, match="404"): + callback.post_report("https://hub.spikeforge.net", "v1", {}, "s") + + +def test_network_error_is_a_callback_error( + monkeypatch: pytest.MonkeyPatch +) -> None: + """A connection failure is a named ``CallbackError``, not a crash.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.URLError("unreachable") + + monkeypatch.setattr(callback.urllib.request, "urlopen", _raise) + with pytest.raises(CallbackError, match="callback delivery failed"): + callback.post_report("https://hub.spikeforge.net", "v1", {}, "s") diff --git a/tests/test_hub_verify_cli.py b/tests/test_hub_verify_cli.py new file mode 100644 index 0000000..572e6fa --- /dev/null +++ b/tests/test_hub_verify_cli.py @@ -0,0 +1,119 @@ +"""CLI orchestration: env reading, stage sequencing, always-posts-a-report.""" + +from typing import Any, Dict, List, Tuple + +import pytest +from hub_verify import cli +from hub_verify.errors import CallbackError, VerificationStepError + +_ENV = { + "SPIKEFORGE_HUB_VERIFY_VERSION_ID": "v1", + "SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL": "https://cdn.example/a.spkf", + "SPIKEFORGE_HUB_API_BASE_URL": "https://hub.example", + "SPIKEFORGE_HUB_VERIFICATION_SECRET": "sekrit", +} + + +def _set_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Populate every required environment variable for ``main()``.""" + for key, value in _ENV.items(): + monkeypatch.setenv(key, value) + + +class _FakeBundle: + """A stand-in with just the attributes ``_run_checks`` reads.""" + + manifest = {"topology": "fc_small"} + spec = None + + +def test_missing_env_var_exits_loudly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing required setting exits rather than crashing obscurely.""" + monkeypatch.delenv( + "SPIKEFORGE_HUB_VERIFY_VERSION_ID", raising=False + ) + with pytest.raises(SystemExit, match="SPIKEFORGE_HUB_VERIFY_VERSION_ID"): + cli.main() + + +def test_a_passing_artifact_posts_passed_true( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Every stage succeeding posts ``passed: true`` with no reasons.""" + _set_env(monkeypatch) + monkeypatch.setattr( + cli, "fetch_artifact", lambda url, dest: dest + ) + monkeypatch.setattr( + cli, "load_and_build", lambda path: (_FakeBundle(), object()) + ) + monkeypatch.setattr( + cli, "run_funnel", + lambda bundle, module, tmp: { + "inspect": {"kind": "state_dict"}, + "compat": {"verdict": "incompatible"}, + "drift": {"within_tolerance": True}, + }, + ) + monkeypatch.setattr(cli, "energy_reports", lambda spec, module: []) + + posted: Dict[str, Any] = {} + monkeypatch.setattr( + cli, + "post_report", + lambda base, vid, report, secret: posted.update(report=report), + ) + assert cli.main() == 0 + assert posted["report"]["passed"] is True + assert posted["report"]["reasons"] == [] + + +def test_a_failing_stage_posts_the_named_reason( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A stage failure posts ``passed: false`` naming which step and why.""" + _set_env(monkeypatch) + monkeypatch.setattr(cli, "fetch_artifact", lambda url, dest: dest) + + def _raise(path: str) -> Tuple[Any, Any]: + raise VerificationStepError("bundle", "weights are not loadable") + + monkeypatch.setattr(cli, "load_and_build", _raise) + + posted: Dict[str, Any] = {} + monkeypatch.setattr( + cli, + "post_report", + lambda base, vid, report, secret: posted.update(report=report), + ) + assert cli.main() == 0 + assert posted["report"]["passed"] is False + reasons: List[str] = posted["report"]["reasons"] + assert reasons == ["bundle: weights are not loadable"] + + +def test_a_failed_callback_exits_loudly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The job fails when the signed report cannot be delivered at all.""" + _set_env(monkeypatch) + monkeypatch.setattr(cli, "fetch_artifact", lambda url, dest: dest) + monkeypatch.setattr( + cli, "load_and_build", lambda path: (_FakeBundle(), object()) + ) + monkeypatch.setattr( + cli, "run_funnel", + lambda bundle, module, tmp: { + "inspect": {}, "compat": {}, "drift": {"within_tolerance": True} + }, + ) + monkeypatch.setattr(cli, "energy_reports", lambda spec, module: []) + + def _raise(*_args: Any) -> None: + raise CallbackError("hub API unreachable") + + monkeypatch.setattr(cli, "post_report", _raise) + with pytest.raises(SystemExit, match="could not deliver"): + cli.main() diff --git a/tests/test_hub_verify_fetch.py b/tests/test_hub_verify_fetch.py new file mode 100644 index 0000000..d9171f8 --- /dev/null +++ b/tests/test_hub_verify_fetch.py @@ -0,0 +1,84 @@ +"""Fetching the dispatched artifact, capped and named on failure.""" + +import io +from typing import Any +from urllib import error as urlerror + +import pytest +from hub_verify import fetch +from hub_verify.errors import FetchError + + +class _FakeResponse: + """A minimal stand-in for ``http.client.HTTPResponse``.""" + + def __init__(self, payload: bytes) -> None: + """Wrap ``payload`` behind a chunked ``read``.""" + self._buffer = io.BytesIO(payload) + + def read(self, size: int) -> bytes: + """Return up to ``size`` bytes, matching the real response API.""" + return self._buffer.read(size) + + def __enter__(self) -> "_FakeResponse": + """Support the ``with urlopen(...) as response`` pattern.""" + return self + + def __exit__(self, *exc_info: Any) -> None: + """Nothing to release for an in-memory buffer.""" + + +def test_fetch_writes_the_payload( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A normal response is streamed to ``dest`` unchanged.""" + payload = b"spkf-bytes" + monkeypatch.setattr( + fetch.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(payload), + ) + dest = str(tmp_path / "artifact.spkf") + fetch.fetch_artifact("https://cdn.example/x", dest) + with open(dest, "rb") as handle: + assert handle.read() == payload + + +def test_fetch_refuses_past_the_byte_cap( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A payload larger than the cap is refused, not buffered in full.""" + monkeypatch.setattr(fetch, "MAX_ARTIFACT_BYTES", 4) + monkeypatch.setattr( + fetch.urllib.request, + "urlopen", + lambda *a, **k: _FakeResponse(b"way too much data"), + ) + with pytest.raises(FetchError, match="byte cap"): + fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a")) + + +def test_http_error_is_a_named_fetch_error( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A non-2xx signed-URL response is a named, not a generic, failure.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.HTTPError("url", 403, "forbidden", {}, None) + + monkeypatch.setattr(fetch.urllib.request, "urlopen", _raise) + with pytest.raises(FetchError, match="HTTP 403"): + fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a")) + + +def test_network_error_is_a_named_fetch_error( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A connection failure is reported by name, not as a raw traceback.""" + + def _raise(*_args: Any, **_kwargs: Any) -> None: + raise urlerror.URLError("no route to host") + + monkeypatch.setattr(fetch.urllib.request, "urlopen", _raise) + with pytest.raises(FetchError, match="network fetch failed"): + fetch.fetch_artifact("https://cdn.example/x", str(tmp_path / "a")) diff --git a/tests/test_hub_verify_pipeline.py b/tests/test_hub_verify_pipeline.py new file mode 100644 index 0000000..844a186 --- /dev/null +++ b/tests/test_hub_verify_pipeline.py @@ -0,0 +1,74 @@ +"""End-to-end: load, funnel, and energy-account a real bundle.""" + +import zipfile +from typing import Any, Dict + +import pytest +import torch +from hub_verify.energy import energy_reports +from hub_verify.errors import VerificationStepError +from hub_verify.funnel import run_funnel +from hub_verify.pipeline import load_and_build + +from spikeforge.network import model_store +from spikeforge.serving import bundle_manifest as bm +from spikeforge.serving.bundle import build +from spikeforge.training.training_engine import TrainingEngine + +_NAME = "hub_verify_ckpt" + + +@pytest.fixture(autouse=True) +def _model_dir(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None: + """Redirect checkpoint reads and writes into a per-test directory.""" + monkeypatch.setattr(model_store, "MODEL_DIR", str(tmp_path)) + + +def _written(tmp_path: Any) -> str: + """Save a small checkpoint, build its bundle, and return its path.""" + torch.manual_seed(0) + engine = TrainingEngine( + dataset="mnist", + num_steps=4, + device="cpu", + topology="fc_small", + topology_params={"hidden": 5, "num_classes": 3}, + ) + engine.save(_NAME) + out = str(tmp_path / "model.spkf") + build(_NAME, out=out) + return out + + +def _tamper_weights(path: str) -> None: + """Flip a byte of the stored weights, invalidating its checksum.""" + with zipfile.ZipFile(path) as archive: + entries = {n: archive.read(n) for n in archive.namelist()} + entries[bm.WEIGHTS_NAME] += b"\x00" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + for name, payload in entries.items(): + archive.writestr(name, payload) + + +def test_a_good_bundle_passes_the_whole_funnel(tmp_path: Any) -> None: + """A real, untampered bundle loads, funnels, and accounts cleanly.""" + out = _written(tmp_path) + bundle, module = load_and_build(out) + funnel: Dict[str, Any] = run_funnel(bundle, module, str(tmp_path)) + assert funnel["inspect"]["kind"] == "state_dict" + assert funnel["drift"]["within_tolerance"] is True + reports = energy_reports(bundle.spec, module) + assert reports + assert all("target" in report for report in reports) + + +def test_a_tampered_bundle_is_rejected_at_the_bundle_stage( + tmp_path: Any, +) -> None: + """A checksum mismatch names the bundle stage, not a generic failure.""" + out = _written(tmp_path) + _tamper_weights(out) + with pytest.raises(VerificationStepError) as info: + load_and_build(out) + assert info.value.step == "bundle" + assert info.value.reason diff --git a/tests/test_hub_verify_report.py b/tests/test_hub_verify_report.py new file mode 100644 index 0000000..63d7251 --- /dev/null +++ b/tests/test_hub_verify_report.py @@ -0,0 +1,44 @@ +"""The verification report's shape and the hub-api trust contract.""" + +from hub_verify.report import build_report, rejection_summary + + +def test_passed_report_carries_a_true_boolean() -> None: + """``passed`` is the JSON boolean hub-api's ``trust.label_for`` reads. + + ``hub_api/catalog/trust.py::label_for`` only shows ``machine-checked`` + when ``version.verification["passed"] is True`` -- not truthy, the + literal boolean -- so this is the one field this report must never get + wrong. + """ + report = build_report( + version_id="v1", + passed=True, + reasons=[], + checks={"bundle": {"ok": True}}, + summary="passed every check", + run_url="https://github.com/x/y/actions/runs/1", + ) + assert report["passed"] is True + assert report["reasons"] == [] + assert report["version_id"] == "v1" + + +def test_failed_report_names_the_reason() -> None: + """A rejection carries the actionable reason, not just a flag.""" + reasons = ["bundle: weights are not loadable: bad magic number"] + report = build_report( + version_id="v2", + passed=False, + reasons=reasons, + checks={"bundle": {"ok": False}}, + summary=rejection_summary(reasons), + run_url="", + ) + assert report["passed"] is False + assert "weights are not loadable" in report["summary"] + + +def test_rejection_summary_of_no_reasons_names_the_bug() -> None: + """An empty reason list on a rejection is itself flagged, not hidden.""" + assert "bug" in rejection_summary([]) diff --git a/tests/test_hub_verify_signing.py b/tests/test_hub_verify_signing.py new file mode 100644 index 0000000..68d32d7 --- /dev/null +++ b/tests/test_hub_verify_signing.py @@ -0,0 +1,33 @@ +"""HMAC signing of the outgoing verification report.""" + +import pytest +from hub_verify import signing + + +def test_matching_secret_verifies() -> None: + """A signature computed and checked with the same secret matches.""" + body = b'{"passed": true}' + header = signing.sign_body(body, "sekrit") + assert header.startswith("sha256=") + assert signing.signature_matches(body, "sekrit", header) + + +def test_wrong_secret_does_not_verify() -> None: + """A signature checked under a different secret does not match.""" + body = b'{"passed": true}' + header = signing.sign_body(body, "sekrit") + assert not signing.signature_matches(body, "wrong", header) + + +def test_tampered_body_does_not_verify() -> None: + """A body byte changed after signing breaks the signature.""" + body = b'{"passed": true}' + header = signing.sign_body(body, "sekrit") + tampered = b'{"passed": false}' + assert not signing.signature_matches(tampered, "sekrit", header) + + +def test_empty_secret_is_refused() -> None: + """Signing under an empty secret is refused rather than silently weak.""" + with pytest.raises(ValueError): + signing.sign_body(b"{}", "") diff --git a/tests/test_serving_bundle.py b/tests/test_serving_bundle.py index c50e0c5..4d53f4d 100644 --- a/tests/test_serving_bundle.py +++ b/tests/test_serving_bundle.py @@ -193,6 +193,23 @@ def test_rejects_missing_required_entry(tmp_path: Any) -> None: DeploymentBundle.load(out) +def test_rejects_oversized_decompressed_bundle( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A bundle whose entries decompress past the cap is refused unread. + + The cap is checked against each entry's declared size before any entry + is decompressed, so a normal small bundle is enough to trigger it once + the cap itself is lowered below the bundle's real total -- no need to + construct an actual multi-hundred-megabyte payload to prove the guard + works. + """ + monkeypatch.setattr(bm, "MAX_DECOMPRESSED_BYTES", 8) + out = _written(tmp_path) + with pytest.raises(BundleFormatError, match="decompressed size"): + DeploymentBundle.load(out) + + def test_rejects_non_zip_payload(tmp_path: Any) -> None: """A file that is not a zip archive is a format error.""" path = tmp_path / "broken.spkf" From 9c503fb0533b25fb4ab3aaf46556484c0682bd87 Mon Sep 17 00:00:00 2001 From: w4ffl35 <25737761+w4ffl35@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:40:34 -0600 Subject: [PATCH 2/4] Refresh the client mirror with the recomposed dashboard client/ is the mirror the Docker image builds and dash.spikeforge.net serves. It was a pre-recompose snapshot: the old card layout, no rail, no docked panes. This replaces it with the dashboard's current source, and regenerates the protocol types from this repository's schemas so the codegen guard still passes. The in-src test file is dropped from the mirror; it needs @types/node, which this client does not carry. --- client/index.html | 18 +- client/src/App.tsx | 127 ++++---- client/src/components/AnalysisPanels.tsx | 4 +- client/src/components/AppHeader.tsx | 58 ++++ client/src/components/ArchitectureStrip.tsx | 99 +++++++ client/src/components/Badge.tsx | 22 ++ client/src/components/Button.tsx | 48 +++ client/src/components/CheckField.tsx | 18 +- client/src/components/Controls.tsx | 120 ++++---- client/src/components/DockPane.tsx | 86 ++++++ client/src/components/EncodingControls.tsx | 21 +- client/src/components/EnergyPanel.tsx | 29 +- client/src/components/FactGrid.tsx | 26 ++ client/src/components/HelpTip.tsx | 77 ++++- client/src/components/HubCompatBadge.tsx | 10 +- client/src/components/HubPanel.tsx | 10 +- client/src/components/IconButton.tsx | 69 +++++ client/src/components/InputSizeField.tsx | 6 +- client/src/components/InspectorSection.tsx | 23 ++ client/src/components/LineChart.tsx | 65 +++- client/src/components/LoadedModelPanel.tsx | 41 ++- client/src/components/Metric.tsx | 19 ++ client/src/components/MetricsPanel.tsx | 35 +-- client/src/components/ModeToggle.tsx | 13 +- client/src/components/ModelPanel.tsx | 14 +- client/src/components/ModelSection.tsx | 17 +- client/src/components/ModelWorkspace.tsx | 149 ++++++++++ client/src/components/NavRail.tsx | 158 ++++++++++ client/src/components/NetworkActivity.tsx | 7 +- client/src/components/NetworkInspector.tsx | 81 +++++ client/src/components/NumberField.tsx | 148 ++++++++++ client/src/components/PanelHeader.tsx | 41 +++ client/src/components/PipelinePanel.tsx | 73 ++--- client/src/components/PipelineRunControls.tsx | 128 ++++++++ client/src/components/PredictionPanel.tsx | 25 +- client/src/components/SampleIndex.tsx | 27 +- client/src/components/SamplePreview.tsx | 36 +++ client/src/components/SelectField.tsx | 8 +- client/src/components/SessionContext.tsx | 70 +++++ client/src/components/SliderField.tsx | 47 --- client/src/components/StageNeuronEditor.tsx | 29 +- client/src/components/StatusBar.tsx | 11 +- client/src/components/StatusIndicator.tsx | 20 ++ client/src/components/TabBar.tsx | 103 ------- client/src/components/TargetsPanel.tsx | 2 + client/src/components/TimeCursor.tsx | 72 ++--- client/src/components/Toolbar.tsx | 23 ++ client/src/components/TopBar.tsx | 71 +++-- client/src/components/TourLauncher.tsx | 18 +- client/src/components/TrainControls.tsx | 24 +- client/src/components/TrainingPanel.tsx | 11 +- client/src/components/TrajectoryChart.tsx | 3 +- client/src/components/ViewerPanels.tsx | 48 ++- client/src/components/chartColors.ts | 8 +- client/src/components/datasetFields.ts | 64 ++++ client/src/components/pipelineInput.ts | 96 ++++++ client/src/components/pipelineSources.ts | 43 +++ client/src/helpText.ts | 4 + client/src/i18n/locales/de.ts | 28 ++ client/src/i18n/locales/es.ts | 28 ++ client/src/i18n/locales/fr.ts | 28 ++ client/src/i18n/locales/it.ts | 28 ++ client/src/i18n/locales/ja.ts | 28 ++ client/src/i18n/locales/ko.ts | 28 ++ client/src/i18n/locales/pt.ts | 28 ++ client/src/i18n/translations.ts | 32 ++ client/src/serve/serveClient.ts | 161 ---------- client/src/styles.css | 4 + client/src/styles/analysis.css | 25 +- client/src/styles/base.css | 254 +++++++++++----- client/src/styles/capsize.css | 17 +- client/src/styles/confirm.css | 9 +- client/src/styles/controls.css | 164 ++++++----- client/src/styles/dock.css | 212 ++++++++++++++ client/src/styles/download.css | 15 +- client/src/styles/energy.css | 26 +- client/src/styles/feedback.css | 44 ++- client/src/styles/hub.css | 114 ++++--- client/src/styles/mode.css | 54 ++-- client/src/styles/model-actions.css | 61 ++-- client/src/styles/model.css | 90 +++--- client/src/styles/panels.css | 59 ++-- client/src/styles/pipeline.css | 41 +-- client/src/styles/primitives.css | 214 ++++++++++++++ client/src/styles/readouts.css | 202 +++++++++++++ client/src/styles/sections.css | 277 ++++++++++-------- client/src/styles/shell.css | 259 ++++++++++++++++ client/src/styles/tabs.css | 125 +++----- client/src/styles/targets.css | 62 ++-- client/src/styles/tour.css | 58 ++-- client/src/styles/viewer.css | 213 ++++++++++---- client/src/tabLabels.ts | 22 ++ client/src/theme.tsx | 24 +- client/vite.config.ts | 33 ++- 94 files changed, 4218 insertions(+), 1572 deletions(-) create mode 100644 client/src/components/AppHeader.tsx create mode 100644 client/src/components/ArchitectureStrip.tsx create mode 100644 client/src/components/Badge.tsx create mode 100644 client/src/components/Button.tsx create mode 100644 client/src/components/DockPane.tsx create mode 100644 client/src/components/FactGrid.tsx create mode 100644 client/src/components/IconButton.tsx create mode 100644 client/src/components/InspectorSection.tsx create mode 100644 client/src/components/Metric.tsx create mode 100644 client/src/components/ModelWorkspace.tsx create mode 100644 client/src/components/NavRail.tsx create mode 100644 client/src/components/NetworkInspector.tsx create mode 100644 client/src/components/NumberField.tsx create mode 100644 client/src/components/PanelHeader.tsx create mode 100644 client/src/components/PipelineRunControls.tsx create mode 100644 client/src/components/SamplePreview.tsx create mode 100644 client/src/components/SessionContext.tsx delete mode 100644 client/src/components/SliderField.tsx create mode 100644 client/src/components/StatusIndicator.tsx delete mode 100644 client/src/components/TabBar.tsx create mode 100644 client/src/components/Toolbar.tsx create mode 100644 client/src/components/datasetFields.ts create mode 100644 client/src/components/pipelineInput.ts create mode 100644 client/src/components/pipelineSources.ts delete mode 100644 client/src/serve/serveClient.ts create mode 100644 client/src/styles/dock.css create mode 100644 client/src/styles/primitives.css create mode 100644 client/src/styles/readouts.css create mode 100644 client/src/styles/shell.css create mode 100644 client/src/tabLabels.ts diff --git a/client/index.html b/client/index.html index d676461..1a52ab5 100644 --- a/client/index.html +++ b/client/index.html @@ -3,14 +3,18 @@ - - Spikeforge Dashboard — Explore Spiking Neural Networks diff --git a/client/src/App.tsx b/client/src/App.tsx index d131eec..b9255f3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,20 +1,16 @@ import { lazy, Suspense, useCallback, useRef } from "react"; import { AnalysisPanels } from "./components/AnalysisPanels"; -import { Controls } from "./components/Controls"; +import { AppHeader } from "./components/AppHeader"; import { DownloadProgress } from "./components/DownloadProgress"; import { EnergyPanel } from "./components/EnergyPanel"; import { HubPanel } from "./components/HubPanel"; -import { LoadedModelPanel } from "./components/LoadedModelPanel"; -import { ModelPanel } from "./components/ModelPanel"; -import { Section } from "./components/Stepper"; +import { ModelWorkspace } from "./components/ModelWorkspace"; +import { NavRail } from "./components/NavRail"; import { StatusBar } from "./components/StatusBar"; -import { TabBar } from "./components/TabBar"; import { TabPanel } from "./components/TabPanel"; import { TargetsPanel } from "./components/TargetsPanel"; -import { TopBar } from "./components/TopBar"; import { TourCard } from "./components/TourCard"; -import { TrainControls } from "./components/TrainControls"; import { TrainingPanel } from "./components/TrainingPanel"; import { ViewerPanels } from "./components/ViewerPanels"; import { useEncodeConfig } from "./hooks/useEncodeConfig"; @@ -122,78 +118,53 @@ export default function App() { return (
-
- training.patch({ mode })} - lessons={LESSONS} - tourOpen={tour.menuOpen} - onToggleTours={tour.toggleMenu} - onOpenTour={tour.openLesson} - /> - - + - {training.state.loaded && ( -
- -
- )} -
+ training.patch({ mode })} + active={tabs.active} + loaded={training.state.loaded} + dataset={config.dataset} + tourOpen={tour.menuOpen} + onToggleTours={tour.toggleMenu} + onOpenTour={tour.openLesson} + />
-
-
- - -
-
- -
-
-
- -
- -
-
+
@@ -236,7 +207,7 @@ export default function App() { -
+
-
+
-
+
+
- +
); } diff --git a/client/src/components/AppHeader.tsx b/client/src/components/AppHeader.tsx new file mode 100644 index 0000000..1f89563 --- /dev/null +++ b/client/src/components/AppHeader.tsx @@ -0,0 +1,58 @@ +import type { ExecutionMode, ModelLoadedPayload } from "../types"; +import type { TabId } from "../tabs"; +import { TAB_LABEL_KEYS } from "../tabLabels"; +import { useI18n } from "../i18n/I18nProvider"; +import { TopBar } from "./TopBar"; +import type { SessionFacts } from "./SessionContext"; + +interface Props { + mode: ExecutionMode; + onModeChange: (mode: ExecutionMode) => void; + /** The workspace (section) currently shown, so the toolbar can name it. */ + active: TabId; + /** Session facts read from App state; an unknown value renders nothing. */ + loaded: ModelLoadedPayload | null; + dataset: string; + tourOpen: boolean; + onToggleTours: () => void; + onOpenTour: (id: string) => void; +} + +/** + * The fixed toolbar. It states where the session is (the active workspace) and + * what it is working on (the loaded checkpoint and the dataset). + * + * The checkpoint's own numbers — accuracy, input mode, hidden width, device — + * are summarised in the network inspector instead, which is where they are + * read and where the device is set; a toolbar is not the place for them. + */ +export function AppHeader({ + mode, + onModeChange, + active, + loaded, + dataset, + tourOpen, + onToggleTours, + onOpenTour, +}: Props) { + const { t } = useI18n(); + const session: SessionFacts = { + model: loaded?.name ?? null, + dataset, + }; + + return ( +
+ +
+ ); +} diff --git a/client/src/components/ArchitectureStrip.tsx b/client/src/components/ArchitectureStrip.tsx new file mode 100644 index 0000000..bc82fee --- /dev/null +++ b/client/src/components/ArchitectureStrip.tsx @@ -0,0 +1,99 @@ +import { useI18n } from "../i18n/I18nProvider"; +import type { TranslationKey } from "../i18n/translations"; +import type { DatasetInfo, EncodeConfig, TrainConfig } from "../types"; +import { FactGrid } from "./FactGrid"; +import type { Fact } from "./FactGrid"; + +interface Props { + config: EncodeConfig; + model: TrainConfig; + /** The configured dataset, once the server has listed it. */ + dataset: DatasetInfo | undefined; + /** Input geometry read off the last frame the server sent, e.g. "28×28". */ + inputDims: string | null; + gpuAvailable: boolean; +} + +/** Where the coding label comes from; the server names the coding type. */ +const CODING_KEYS: Record = { + rate: "coding.rate", + latency: "coding.latency", + delta: "coding.delta", + random: "coding.random", +}; + +/** One stage of the chain the product builds. */ +interface Stage { + key: string; + label: string; + value: string; + unit?: string; +} + +/** + * The architecture the application is actually configured to build: + * Input → Encoder → Hidden → Output, with the values it reads for each. + * + * A stage whose value the application does not hold shows a dash rather than + * a guess; there is no parameter count or benchmark here because nothing in + * the client's state can produce one. + */ +export function ArchitectureStrip({ + config, + model, + dataset, + inputDims, + gpuAvailable, +}: Props) { + const { t } = useI18n(); + + const stages: Stage[] = [ + { key: "input", label: t("arch.input"), value: inputDims ?? "—" }, + { + key: "encoder", + label: t("arch.encoder"), + value: t(CODING_KEYS[config.coding]), + }, + { + key: "hidden", + label: t("arch.hidden"), + value: String(model.hidden), + unit: t("arch.neurons"), + }, + { + key: "output", + label: t("arch.output"), + value: dataset === undefined ? "—" : String(dataset.classes), + unit: t("arch.classes"), + }, + ]; + + const facts: Fact[] = [ + { label: t("field.dataset"), value: dataset?.name ?? config.dataset }, + { label: "num_steps", value: String(config.num_steps) }, + { + label: t("arch.device"), + value: `${model.device.toUpperCase()} · ${ + gpuAvailable ? t("arch.gpuAvailable") : t("arch.gpuUnavailable") + }`, + }, + ]; + + return ( +
+
{t("arch.title")}
+
    + {stages.map((stage) => ( +
  1. + {stage.label} + {stage.value} + {stage.unit !== undefined && ( + {stage.unit} + )} +
  2. + ))} +
+ +
+ ); +} diff --git a/client/src/components/Badge.tsx b/client/src/components/Badge.tsx new file mode 100644 index 0000000..4ea5814 --- /dev/null +++ b/client/src/components/Badge.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from "react"; + +/** Semantic tone; `neutral` is the uncoloured default. */ +export type BadgeTone = "neutral" | "ok" | "warn" | "bad"; + +interface Props { + children: ReactNode; + tone?: BadgeTone; + title?: string; +} + +/** A short status marker: a verdict, a mode, a cached/downloaded state. */ +export function Badge({ children, tone = "neutral", title }: Props) { + return ( + + {children} + + ); +} diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx new file mode 100644 index 0000000..439c6c1 --- /dev/null +++ b/client/src/components/Button.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; + +/** + * `primary` is the single filled-accent action; `danger` is destructive; + * `ghost` is routine and borderless. The default is a neutral control. + */ +export type ButtonVariant = "neutral" | "primary" | "danger" | "ghost"; + +interface Props { + children: ReactNode; + onClick: () => void; + variant?: ButtonVariant; + disabled?: boolean; + /** Toolbar-height variant. */ + small?: boolean; + block?: boolean; + title?: string; + testId?: string; +} + +/** Base button. Geometry and tones come from the design tokens. */ +export function Button({ + children, + onClick, + variant = "neutral", + disabled = false, + small = false, + block = false, + title, + testId, +}: Props) { + const classes = ["btn"]; + if (variant !== "neutral") classes.push(variant); + if (small) classes.push("sm"); + if (block) classes.push("block"); + return ( + + ); +} diff --git a/client/src/components/CheckField.tsx b/client/src/components/CheckField.tsx index 3b47c74..c36d1f0 100644 --- a/client/src/components/CheckField.tsx +++ b/client/src/components/CheckField.tsx @@ -19,17 +19,15 @@ export function CheckField({ return ( ); } diff --git a/client/src/components/Controls.tsx b/client/src/components/Controls.tsx index 7016236..8d86f66 100644 --- a/client/src/components/Controls.tsx +++ b/client/src/components/Controls.tsx @@ -1,84 +1,68 @@ import { HELP } from "../helpText"; import { useI18n } from "../i18n/I18nProvider"; -import type { TranslationKey } from "../i18n/translations"; -import type { DatasetInfo, EncodeConfig, TrainConfig } from "../types"; +import type { DatasetInfo, EncodeConfig } from "../types"; +import { datasetFacts, datasetOptions } from "./datasetFields"; import { EncodingControls } from "./EncodingControls"; -import { ModelSection } from "./ModelSection"; +import { FactGrid } from "./FactGrid"; +import { NumberField } from "./NumberField"; +import { SamplePreview } from "./SamplePreview"; import { SelectField } from "./SelectField"; -import type { Option } from "./SelectField"; -import { SliderField } from "./SliderField"; import { Section } from "./Stepper"; interface Props { config: EncodeConfig; - model: TrainConfig; datasets: DatasetInfo[]; - gpuAvailable: boolean; - /** Registry names for the architecture pickers, empty until listed. */ - topologies: string[]; - neurons: string[]; - surrogates: string[]; + /** The input frame the server last sent, for the preview. */ + sample: number[][] | null; + /** Whole-sample ON/OFF frame, for an event dataset. */ + eventFrame: number[][] | null; /** True when a checkpoint is loaded: architecture/encoding are read-only. */ locked: boolean; onChange: (patch: Partial) => void; - onModelChange: (patch: Partial) => void; onSelectSample: (patch: Partial) => void; } -/** Human label for a dataset option, including modality and availability. */ -function datasetLabel( - d: DatasetInfo, - t: (key: TranslationKey) => string, -): string { - const base = `${d.name} (${d.classes} ${t("dataset.classes")})`; - if (d.modality !== "event") return base; - if (d.available === false) { - return `${base} — ${t("dataset.unavailable")}`; - } - return `${base} — ${t("dataset.events")}`; -} - -/** Build the dataset dropdown, disabling event sets with no tonic loader. */ -function datasetOptions( - datasets: DatasetInfo[], - current: string, - t: (key: TranslationKey) => string, -): Option[] { - if (datasets.length === 0) return [{ value: current, label: current }]; - return datasets.map((d) => ({ - value: d.name, - label: datasetLabel(d, t), - // Unavailable events would otherwise fail inside the download worker. - disabled: d.modality === "event" && d.available === false, - })); -} - -export function Controls(props: Props) { +/** + * The data and encoding editor: the pane between the asset browser and the + * network inspector. + * + * The content tiles into two columns once the pane is wide enough — dataset + * and its sample on the left, encoding on the right — so a wide window gets + * two readable columns instead of one run of full-width controls. Below that + * width it is one column again. + * + * It answers three questions in the order they are asked — which dataset, what + * the application knows about it (including a look at the frame itself), and + * how that frame is turned into spikes. The notes at the top are the + * configuration's own validation: a locked config, or a dataset whose learning + * rules differ from the default. + */ +export function Controls({ + config, + datasets, + sample, + eventFrame, + locked, + onChange, + onSelectSample, +}: Props) { const { t } = useI18n(); - const { - config, - model, - datasets, - gpuAvailable, - topologies, - neurons, - surrogates, - locked, - onChange, - onModelChange, - onSelectSample, - } = props; - const selected = datasets.find((d) => d.name === config.dataset); const eventMode = selected?.modality === "event"; const eventUnavailable = eventMode && selected?.available === false; return ( -
+
{locked &&
{t("controls.locked")}
} -
+
+ +
{HELP.event_training}

)} - + + onChange({ subset: v })} /> - onChange({ batch_size: v })} />
+
+
- -
- -
); diff --git a/client/src/components/DockPane.tsx b/client/src/components/DockPane.tsx new file mode 100644 index 0000000..e3cdcd1 --- /dev/null +++ b/client/src/components/DockPane.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import { useI18n } from "../i18n/I18nProvider"; +import { IconButton } from "./IconButton"; + +/** A pane's width policy: fixed for a browser or an inspector, free + * otherwise. */ +type PaneVariant = "assets" | "editor" | "inspector"; + +/** + * Viewport width below which a pane starts collapsed, by role. + * + * The order is deliberate and follows each pane's weight: the asset browser + * gives way first, the inspector second, and the workspace only stacks much + * later (see dock.css). The editor is never in this table — it is where the + * work happens, so it keeps whatever width is left. + */ +const COLLAPSE_BELOW: Partial> = { + assets: 1200, + inspector: 1040, +}; + +/** + * Whether a pane is open at first paint. + * + * Read once, from the viewport, because a pane the user has expanded by hand + * should stay expanded: re-deriving it on every resize would fight the + * toggle. A pane with no entry in the table is always open. + */ +function initiallyOpen(variant: PaneVariant): boolean { + const below = COLLAPSE_BELOW[variant]; + return below === undefined || window.innerWidth > below; +} + +interface Props { + variant: PaneVariant; + /** Pane heading, rendered in the head row. */ + heading: string; + /** Small right-aligned fact for the head row, e.g. the current dataset. */ + meta?: string; + children: ReactNode; +} + +/** + * One docked pane: a full-height child of a workspace, separated from its + * neighbour by a single hairline. It has no margin, no radius, and no card on + * a background — the surface step and the hairline are the whole hierarchy. + * + * The head is a fixed row and the body scrolls beneath it, so a long form + * keeps its pane heading and a short one keeps its empty space inside the + * pane instead of leaving a gap at the bottom of the window. + * + * An asset browser or an inspector collapses to a labelled strip on a narrow + * desktop and expands again with its toggle. The body is hidden rather than + * unmounted, so nothing inside a collapsed pane is torn down or loses state. + */ +export function DockPane({ variant, heading, meta, children }: Props) { + const { t } = useI18n(); + const collapsible = COLLAPSE_BELOW[variant] !== undefined; + const [open, setOpen] = useState(() => initiallyOpen(variant)); + + return ( +
+
+ {heading} + {meta !== undefined && {meta}} + {collapsible && ( + setOpen((isOpen) => !isOpen)} + /> + )} +
+ +
+ ); +} diff --git a/client/src/components/EncodingControls.tsx b/client/src/components/EncodingControls.tsx index 93c3c51..3070012 100644 --- a/client/src/components/EncodingControls.tsx +++ b/client/src/components/EncodingControls.tsx @@ -3,8 +3,8 @@ import { useI18n } from "../i18n/I18nProvider"; import type { EncodeConfig } from "../types"; import { CheckField } from "./CheckField"; import { InputSizeField } from "./InputSizeField"; +import { NumberField } from "./NumberField"; import { SelectField } from "./SelectField"; -import { SliderField } from "./SliderField"; import { Section } from "./Stepper"; interface Props { @@ -22,6 +22,10 @@ interface Props { * Event datasets carry their own spikes and time bins, so every coding * control is disabled with an explicit note rather than being silently * ignored; only the playback interval stays adjustable. + * + * The timestep count and the playback interval are boxes — they are exact + * settings, not something to sweep. `gain` is drawn as a slider beside its + * box, because for a rate encode the value is found by feel. */ export function EncodingControls({ config, @@ -59,7 +63,7 @@ export function EncodingControls({ onChange={(v) => set({ coding: v as EncodeConfig["coding"] })} /> - set({ num_steps: v })} /> - {!eventMode && config.coding === "rate" && ( - set({ gain: v })} @@ -106,7 +111,7 @@ export function EncodingControls({ {!eventMode && config.coding === "latency" && ( <> - set({ tau: v })} /> - -
+
SOP {count(report.ops.sop)} MAC @@ -101,25 +102,25 @@ export function EnergyPanel({ payload, targets, loading, onRun }: Props) { return (
-
- - {t("energy.title")} - - - - - -
+ + } + /> {t("hub.empty")}
) : ( -
    +
      {entries.map((entry) => ( void; + /** When set, the control is an anchor that opens in a new tab. */ + href?: string; + disabled?: boolean; + /** Tooltip text when it should differ from the accessible name. */ + title?: string; + /** Extra classes for a size or colour override. */ + className?: string; + /** `aria-expanded` for a button that opens a menu. */ + expanded?: boolean; + /** `aria-haspopup="menu"` for a button that opens a menu. */ + menu?: boolean; +} + +/** Icon-only control: 28x28, neutral until hovered. */ +export function IconButton({ + icon: Icon, + label, + onClick, + href, + disabled = false, + title, + className, + expanded, + menu, +}: Props) { + const classes = className === undefined ? "icon-btn" : `icon-btn ${className}`; + const glyph =