diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5c8e22..5063db6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install the core dev extra run: | @@ -56,7 +55,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip - name: Install torch CPU wheels run: | @@ -89,7 +87,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -141,7 +138,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -182,7 +178,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -243,7 +238,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install torch CPU wheels run: | @@ -310,7 +304,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install docs dependencies run: | @@ -337,7 +330,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Install the renderer PyPI itself uses run: | @@ -362,8 +354,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: client/package-lock.json - name: Install client dependencies working-directory: client @@ -405,7 +395,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - cache: pip - name: Build the core wheel with no extras run: | 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/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 =