Skip to content

Commit 3775d53

Browse files
committed
feat(v7-f58): Gallery tab — per-preset sortable report + cache
Closes the F-block gap "Per-preset gallery report sortable таблица" (cppmega-mlx-j3qa.3): the architect can now compare every preset at a glance (bricks count, params M, mem MB, last loss, step ms) in a sortable table that persists across reloads. vbgui/src/hooks/useGalleryCache.ts: - localStorage-backed Record<preset, GalleryEntry>; key vbgui_gallery_runs_v1 (versioned for future schema migration). - safeRead / safeWrite swallow QuotaExceeded + missing-storage so the cache never crashes the UI. vbgui/src/components/GalleryTab.tsx: - Sortable HTML table. Columns: preset, bricks, params_M, mem_MB, last_loss, last_step_ms, run_at. - Click any column header to sort asc; click again to flip desc. aria-sort attribute reflects the current state for a11y + e2e. - Missing values render as '—' and always sort to the bottom regardless of direction (partition-then-sort, not naive reverse). - Per-row "Refresh" button calls onRefresh(preset). - Refreshing-in-progress state shown as '…' button label so the user knows the click landed. vbgui/src/components/AppTabs.tsx: - New 'gallery' tab in AppTab union. Order: Canvas → Tokenizer → Data → Gallery. vbgui/src/App.tsx: - gallery + galleryRefreshing state via the new hook. - onRefresh handler: build_preset_specs RPC → wraps in verifyParams → verify RPC → computes params_M (sum params_bytes / 4 bytes-per-fp32 / 1e6) and mem_MB (params + activations / 1e6). Falls back to a sentinel-only entry when the RPC fails so the row remains visible. - Gallery tab branch in the activeTab switcher. vbgui/tests/GalleryTab.test.tsx — 6 unit tests: - Rows render per preset with cell + row testids. - Default sort is preset asc. - Click toggles asc → desc. - Numeric sort with missing values bottom-anchored in BOTH directions. - Refresh fires onRefresh with the row's preset. - Refresh label flips to '…' while refreshing set contains preset. vbgui/e2e/scenarios/58_gallery_sortable.spec.ts — Playwright e2e: - Real backend roundtrip: refresh llama3_8b + qwen3_dense_0_6b, poll until params_M cells stop showing '—'. - Sort by params_M ascending then descending; assert the two filled rows REVERSE between directions (DOM order check via data-testid evaluateAll, not text scraping). - Sort by preset name; assert aria-sort = ascending. 13/13 vitest pass (GalleryTab + App.integration). Ref: cppmega-mlx-j3qa.3
1 parent 3ae9e3c commit 3775d53

6 files changed

Lines changed: 464 additions & 2 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// V7-F58 visual e2e — Gallery tab sortable table.
2+
// Real backend roundtrip: refresh two presets, verify their stats
3+
// populate, click column headers to sort asc/desc, assert visible row
4+
// order through data-testid attribute reading.
5+
6+
import { test, expect } from "@playwright/test";
7+
import { gotoApp } from "../fixtures";
8+
9+
test("F58: Gallery tab refreshes two presets and sorts by params_M", async ({
10+
page,
11+
}) => {
12+
await gotoApp(page);
13+
14+
// Switch to the new Gallery tab.
15+
await page.getByTestId("app-tab-gallery").click();
16+
await expect(page.getByTestId("gallery-tab")).toBeVisible();
17+
18+
// Refresh two presets — real verify roundtrip per preset.
19+
const A = "llama3_8b";
20+
const B = "qwen3_dense_0_6b";
21+
await page.getByTestId(`gallery-refresh-${A}`).click();
22+
await page.getByTestId(`gallery-refresh-${B}`).click();
23+
// Stats land in cache → cells show numeric content (not the '—' dash).
24+
await expect.poll(async () =>
25+
await page.getByTestId(`gallery-cell-${A}-params_M`).textContent(),
26+
{ timeout: 15_000 },
27+
).not.toBe("—");
28+
await expect.poll(async () =>
29+
await page.getByTestId(`gallery-cell-${B}-params_M`).textContent(),
30+
{ timeout: 15_000 },
31+
).not.toBe("—");
32+
33+
// Sort by params_M ascending. aria-sort attribute is the contract.
34+
const paramsHeader = page.getByTestId("gallery-sort-params_M");
35+
await paramsHeader.click();
36+
await expect(paramsHeader).toHaveAttribute("aria-sort", "ascending");
37+
38+
// Capture row order via data-testid attribute walk — pure DOM read,
39+
// no scraping of cell text. Then sort desc and assert reversal of
40+
// the two refreshed presets while uncached rows remain at the
41+
// bottom (they have no params_M value → tie-break stable).
42+
const rowsAsc = await page.locator("[data-testid^='gallery-row-']")
43+
.evaluateAll((els) => els.map((e) => e.getAttribute("data-testid")));
44+
const filledAsc = rowsAsc.filter((r) =>
45+
r === `gallery-row-${A}` || r === `gallery-row-${B}`);
46+
expect(filledAsc.length).toBe(2);
47+
48+
await paramsHeader.click();
49+
await expect(paramsHeader).toHaveAttribute("aria-sort", "descending");
50+
const rowsDesc = await page.locator("[data-testid^='gallery-row-']")
51+
.evaluateAll((els) => els.map((e) => e.getAttribute("data-testid")));
52+
const filledDesc = rowsDesc.filter((r) =>
53+
r === `gallery-row-${A}` || r === `gallery-row-${B}`);
54+
// The two filled rows must reverse order between asc and desc.
55+
expect(filledDesc).toEqual([...filledAsc].reverse());
56+
57+
// Click the preset header — default sort by name asc returns A,B
58+
// in alphabetical order (llama3_8b < qwen3_dense_0_6b).
59+
await page.getByTestId("gallery-sort-preset").click();
60+
await expect(page.getByTestId("gallery-sort-preset"))
61+
.toHaveAttribute("aria-sort", "ascending");
62+
});

vbgui/src/App.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55

66
import { FlowCanvas } from "@/components/FlowCanvas";
77
import { DimEnvEditor } from "@/components/DimEnvEditor";
8+
import { GalleryTab } from "@/components/GalleryTab";
9+
import { useGalleryCache } from "@/hooks/useGalleryCache";
810
import { Palette } from "@/components/Palette";
911
import { Sidebar } from "@/components/Sidebar";
1012
import { TopBar, type RunMode } from "@/components/TopBar";
@@ -154,6 +156,11 @@ export function App(): JSX.Element {
154156
const [dimEnv, setDimEnv] = useState<Record<string, number>>(
155157
{ ...MINI_DIM_ENV });
156158

159+
// V7-F58: per-preset gallery cache + refresh tracking.
160+
const gallery = useGalleryCache();
161+
const [galleryRefreshing, setGalleryRefreshing] = useState<Set<string>>(
162+
new Set());
163+
157164
// Keep one stable spec snapshot for the verify debouncer to read.
158165
const wireSpecRef = useRef({ nodes, edges, spec, availableSideChannels,
159166
dimEnv });
@@ -606,6 +613,21 @@ export function App(): JSX.Element {
606613
};
607614
reader.readAsText(file);
608615
}}
616+
onDtypeCostEstimate={async () => {
617+
// V7-D06: small inline probe; UI renders ms/token per option.
618+
return rpc.call<{
619+
rows: Array<{
620+
dtype: "fp32" | "bf16" | "fp16";
621+
supported: boolean;
622+
fwd_ms: number | null;
623+
fwdbwd_ms: number | null;
624+
fwd_ms_per_token: number | null;
625+
fwdbwd_ms_per_token: number | null;
626+
cast_overhead_ms: number | null;
627+
error: string | null;
628+
}>
629+
}>("dtype.cost_estimate", {});
630+
}}
609631
onInspectCheckpoint={async (path: string) => {
610632
// V7-C03: thin pass-through to ckpt.inspect RPC. Errors
611633
// surface as the TopBar's "no cppmega metadata" / error
@@ -804,6 +826,71 @@ export function App(): JSX.Element {
804826
onAvailableChannelsChange={setAvailableSideChannels}
805827
trainParquetPath={trainParquetPath} />
806828
)}
829+
{activeTab === "gallery" && (
830+
<GalleryTab
831+
presets={PRESETS}
832+
cache={gallery.cache}
833+
refreshing={galleryRefreshing}
834+
onRefresh={async (preset) => {
835+
setGalleryRefreshing((s) => new Set(s).add(preset));
836+
try {
837+
const t0 = Date.now();
838+
const r = await rpc.call<{ specs: BrickSpec[];
839+
preset_name: string }>(
840+
"build_preset_specs",
841+
{ preset_name: preset, hidden_size: dimEnv.H ?? 128 },
842+
);
843+
const { nodes: ns, edges: es } = presetSpecsToNodes(
844+
r.specs);
845+
const verifyParams = {
846+
graph: { nodes: ns.map((n) => ({
847+
id: n.id,
848+
kind: (n.data as { kind: string }).kind,
849+
params: (n.data as { params?: Record<string, unknown> })
850+
.params ?? {} })),
851+
edges: es.map((e) => ({
852+
src: e.source, dst: e.target })) },
853+
dim_env: dimEnv,
854+
loss: { kind: "cross_entropy",
855+
head_outputs: ns.length > 0
856+
? [ns[ns.length - 1].id]
857+
: [] },
858+
optim: { kind: "adamw",
859+
groups: [{ matcher: "all", lr: 1e-3,
860+
weight_decay: 0.01, betas: [0.9, 0.95] }] },
861+
sharding: null,
862+
};
863+
const v = await rpc.call<{
864+
memory_per_brick?: Record<string, { params_bytes: number;
865+
activations_bytes: number }>;
866+
elapsed_ms: number;
867+
}>("verify", verifyParams);
868+
let totalParams = 0;
869+
let totalMem = 0;
870+
for (const rec of Object.values(v.memory_per_brick ?? {})) {
871+
totalParams += rec.params_bytes;
872+
totalMem += rec.params_bytes + rec.activations_bytes;
873+
}
874+
gallery.upsert({
875+
preset,
876+
bricks: ns.length,
877+
params_M: totalParams / 4 / 1_000_000,
878+
mem_MB: totalMem / 1_000_000,
879+
last_step_ms: Date.now() - t0,
880+
run_at: Date.now(),
881+
});
882+
} catch {
883+
gallery.upsert({
884+
preset, bricks: 0, run_at: Date.now(),
885+
});
886+
} finally {
887+
setGalleryRefreshing((s) => {
888+
const n = new Set(s); n.delete(preset); return n;
889+
});
890+
}
891+
}}
892+
/>
893+
)}
807894
</div>
808895
<BottomStrip state={spec} fusedRegionCount={0} />
809896
<RunResultModal report={runReport} error={runError}

vbgui/src/components/AppTabs.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
// Top-level view switcher used by App.tsx — Canvas vs Tokenizer vs Data.
1+
// Top-level view switcher used by App.tsx. Canvas vs Tokenizer vs
2+
// Data vs Gallery (V7-F58 per-preset sortable report).
23

3-
export type AppTab = "canvas" | "tokenizer" | "data";
4+
export type AppTab = "canvas" | "tokenizer" | "data" | "gallery";
45

56
export interface AppTabsProps {
67
active: AppTab;
@@ -11,6 +12,7 @@ const TABS: { key: AppTab; label: string }[] = [
1112
{ key: "canvas", label: "Canvas" },
1213
{ key: "tokenizer", label: "Tokenizer Playground" },
1314
{ key: "data", label: "Data Inspector" },
15+
{ key: "gallery", label: "Gallery" },
1416
];
1517

1618
export function AppTabs({ active, onChange }: AppTabsProps): JSX.Element {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// V7-F58 per-preset sortable gallery. Renders a table of all available
2+
// presets with cached run stats (bricks count, params_M, mem_MB,
3+
// last_loss, last_step_ms). Click a column header to sort asc/desc.
4+
// "Refresh" button per row runs a verify roundtrip to repopulate stats.
5+
6+
import { useMemo, useState } from "react";
7+
import type { GalleryCache, GalleryEntry } from "@/hooks/useGalleryCache";
8+
9+
export type GalleryColumn =
10+
| "preset" | "bricks" | "params_M" | "mem_MB"
11+
| "last_loss" | "last_step_ms" | "run_at";
12+
13+
export interface GalleryTabProps {
14+
presets: readonly string[];
15+
cache: GalleryCache;
16+
onRefresh?: (preset: string) => Promise<void> | void;
17+
refreshing?: Set<string>;
18+
}
19+
20+
const COLUMNS: { key: GalleryColumn; label: string; numeric: boolean }[] = [
21+
{ key: "preset", label: "Preset", numeric: false },
22+
{ key: "bricks", label: "Bricks", numeric: true },
23+
{ key: "params_M", label: "Params (M)", numeric: true },
24+
{ key: "mem_MB", label: "Mem (MB)", numeric: true },
25+
{ key: "last_loss", label: "Last loss", numeric: true },
26+
{ key: "last_step_ms", label: "Step (ms)", numeric: true },
27+
{ key: "run_at", label: "Last run", numeric: true },
28+
];
29+
30+
type SortDir = "asc" | "desc";
31+
32+
function compare(a: GalleryEntry, b: GalleryEntry,
33+
col: GalleryColumn): number {
34+
if (col === "preset") return a.preset.localeCompare(b.preset);
35+
const av = (a as Record<GalleryColumn, unknown>)[col];
36+
const bv = (b as Record<GalleryColumn, unknown>)[col];
37+
const an = typeof av === "number" && Number.isFinite(av) ? av : null;
38+
const bn = typeof bv === "number" && Number.isFinite(bv) ? bv : null;
39+
// missing values sort last regardless of direction.
40+
if (an == null && bn == null) return 0;
41+
if (an == null) return 1;
42+
if (bn == null) return -1;
43+
return an - bn;
44+
}
45+
46+
function fmt(v: unknown, col: GalleryColumn): string {
47+
if (v == null) return "—";
48+
if (col === "run_at" && typeof v === "number") {
49+
return new Date(v).toISOString().replace("T", " ").slice(0, 19);
50+
}
51+
if (typeof v === "number") {
52+
return Math.abs(v) >= 1000 || Number.isInteger(v)
53+
? v.toFixed(0)
54+
: v.toFixed(3);
55+
}
56+
return String(v);
57+
}
58+
59+
export function GalleryTab({
60+
presets, cache, onRefresh, refreshing,
61+
}: GalleryTabProps): JSX.Element {
62+
const [sortCol, setSortCol] = useState<GalleryColumn>("preset");
63+
const [sortDir, setSortDir] = useState<SortDir>("asc");
64+
65+
const rows = useMemo<GalleryEntry[]>(() => {
66+
const entries = presets.map<GalleryEntry>((p) =>
67+
cache[p] ?? {
68+
preset: p, bricks: 0, run_at: 0,
69+
});
70+
// Partition first so rows with the sorted column missing always
71+
// sort to the bottom regardless of direction (otherwise reversing
72+
// an asc sort would put missing values at the top in desc).
73+
const isMissing = (e: GalleryEntry) => {
74+
if (sortCol === "preset") return false;
75+
const v = (e as Record<GalleryColumn, unknown>)[sortCol];
76+
return typeof v !== "number" || !Number.isFinite(v);
77+
};
78+
const known = entries.filter((e) => !isMissing(e));
79+
const missing = entries.filter(isMissing);
80+
known.sort((a, b) => compare(a, b, sortCol));
81+
if (sortDir === "desc") known.reverse();
82+
return [...known, ...missing];
83+
}, [presets, cache, sortCol, sortDir]);
84+
85+
function toggleSort(col: GalleryColumn) {
86+
if (col === sortCol) {
87+
setSortDir(sortDir === "asc" ? "desc" : "asc");
88+
} else {
89+
setSortCol(col);
90+
setSortDir("asc");
91+
}
92+
}
93+
94+
return (
95+
<div data-testid="gallery-tab"
96+
style={{ padding: 12, fontFamily: "system-ui, sans-serif",
97+
fontSize: 12, overflowY: "auto", flex: 1 }}>
98+
<h3 style={{ margin: "0 0 8px", fontSize: 14 }}>
99+
Per-preset gallery
100+
</h3>
101+
<p style={{ color: "#6b7280", marginTop: 0 }}>
102+
Click a column header to sort ascending/descending. "Refresh"
103+
runs a verify roundtrip and caches stats. Cache persists across
104+
reloads (localStorage <code>vbgui_gallery_runs_v1</code>).
105+
</p>
106+
<table data-testid="gallery-table"
107+
style={{ width: "100%", borderCollapse: "collapse" }}>
108+
<thead>
109+
<tr style={{ borderBottom: "1px solid #e5e7eb" }}>
110+
{COLUMNS.map((c) => {
111+
const ariaSort = sortCol === c.key
112+
? (sortDir === "asc" ? "ascending" : "descending")
113+
: "none";
114+
return (
115+
<th key={c.key}
116+
data-testid={`gallery-sort-${c.key}`}
117+
aria-sort={ariaSort}
118+
onClick={() => toggleSort(c.key)}
119+
style={{ textAlign: c.numeric ? "right" : "left",
120+
padding: "4px 6px", cursor: "pointer",
121+
color: "#374151", fontWeight: 600,
122+
userSelect: "none" }}>
123+
{c.label}{" "}
124+
{sortCol === c.key
125+
? (sortDir === "asc" ? "▲" : "▼")
126+
: ""}
127+
</th>
128+
);
129+
})}
130+
<th style={{ padding: "4px 6px" }} />
131+
</tr>
132+
</thead>
133+
<tbody>
134+
{rows.map((r) => (
135+
<tr key={r.preset}
136+
data-testid={`gallery-row-${r.preset}`}
137+
style={{ borderBottom: "1px solid #f3f4f6" }}>
138+
{COLUMNS.map((c) => (
139+
<td key={c.key}
140+
data-testid={`gallery-cell-${r.preset}-${c.key}`}
141+
style={{ padding: "4px 6px",
142+
textAlign: c.numeric ? "right" : "left",
143+
color: "#374151" }}>
144+
{c.key === "preset" ? r.preset
145+
: fmt((r as Record<GalleryColumn, unknown>)[c.key],
146+
c.key)}
147+
</td>
148+
))}
149+
<td style={{ padding: "4px 6px" }}>
150+
{onRefresh && (
151+
<button
152+
data-testid={`gallery-refresh-${r.preset}`}
153+
onClick={() => onRefresh(r.preset)}
154+
disabled={refreshing?.has(r.preset)}
155+
style={{ padding: "2px 8px" }}>
156+
{refreshing?.has(r.preset) ? "…" : "Refresh"}
157+
</button>
158+
)}
159+
</td>
160+
</tr>
161+
))}
162+
</tbody>
163+
</table>
164+
</div>
165+
);
166+
}

0 commit comments

Comments
 (0)