jimmy60504's picture
Update QML Classifier Explorer
7fab05e verified
import {
pageTitle, pageSubtitle, exportStatus,
runSelect, runNote, resultsTableBody,
stepSlider, currentStepLabel,
timelineCaption, trainAccPill, testAccPill, chartEmpty,
experimentMeta, datasetsImage,
boundaryPlots, accPills,
lossChart,
homeButtons,
topButtons,
state,
} from "./dom.js";
import { bindImageLightbox, bindAnalysisModal, maybeShowAnalysisHint } from "./overlays.js";
import { renderBoundarySurface, renderAccuracyChart, renderEmptyState, DEFAULT_CAMERA, MOONS_DEFAULT_CAMERA, TOP_CAMERA, MOONS_TOP_CAMERA } from "./charts.js";
import {
formatMetric, formatInteger, appendMetaRow, withCacheBust,
setLoadingState, loadManifest, loadRunData, loadRunChunk, loadRuntimeSource,
} from "./data.js";
const METHODS = ["explicit", "kernel", "reuploading"];
const DATASETS = ["circle", "moons"];
// ── helpers ───────────────────────────────────────────────────────────────────
function formatAcc(v) {
if (v === null || v === undefined) return "β€”";
return (v * 100).toFixed(1) + "%";
}
// ── chunk helpers ─────────────────────────────────────────────────────────────
function getChunkPaths(data) {
return (data.timeline_chunks || []).map((c) => c.path).filter(Boolean);
}
function updatePrefetchProgress(completed, total) {
if (!total) return;
const percent = 82 + (completed / total) * 18;
setLoadingState({
visible: completed < total,
label: `Streaming epoch chunks ${completed}/${total}`,
percent,
status: completed < total ? "streaming" : "ready",
});
}
async function fetchChunkIntoCache(chunkPath, loadToken, { announce = false } = {}) {
if (state.currentRunChunkCache[chunkPath]) {
return state.currentRunChunkCache[chunkPath];
}
if (state.currentRunChunkInflight[chunkPath]) {
return state.currentRunChunkInflight[chunkPath];
}
if (announce) {
const epochMatch = chunkPath.match(/_epoch_(\d+)\.json$/);
const epochLabel = epochMatch ? String(Number(epochMatch[1])) : "?";
setLoadingState({
visible: true,
label: `Loading epoch ${epochLabel} boundaries`,
percent: 92,
status: "rendering",
});
}
const promise = loadRunChunk(chunkPath, loadToken)
.then((chunk) => {
state.currentRunChunkCache[chunkPath] = chunk;
delete state.currentRunChunkInflight[chunkPath];
return chunk;
})
.catch((error) => {
delete state.currentRunChunkInflight[chunkPath];
throw error;
});
state.currentRunChunkInflight[chunkPath] = promise;
return promise;
}
function prefetchChunkStream(data, loadToken, prefetchToken) {
const chunkPaths = getChunkPaths(data);
if (!chunkPaths.length) return;
const pump = async () => {
updatePrefetchProgress(Object.keys(state.currentRunChunkCache).length, chunkPaths.length);
for (const chunkPath of chunkPaths) {
if (prefetchToken !== state.activePrefetchToken || loadToken !== state.activeLoadToken) return;
await fetchChunkIntoCache(chunkPath, loadToken);
updatePrefetchProgress(Object.keys(state.currentRunChunkCache).length, chunkPaths.length);
if (prefetchToken !== state.activePrefetchToken || loadToken !== state.activeLoadToken) return;
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
updatePrefetchProgress(chunkPaths.length, chunkPaths.length);
};
pump().catch((error) => console.warn("Chunk prefetch stopped:", error));
}
/**
* Resolve the full step payload for a given index.
* - Inline mode: step already has `boundaries` β†’ return as-is.
* - Chunked mode: step has `chunk_path` β†’ load chunk, find step by epoch.
*/
async function resolveStepPayload(data, index, loadToken) {
const steps = data.timeline_steps || [];
const summaryStep = steps[index];
if (!summaryStep) return null;
// Inline: boundaries already present
if (summaryStep.boundaries) return summaryStep;
// Chunked: fetch from chunk file
if (!summaryStep.chunk_path) return summaryStep;
const chunkPath = summaryStep.chunk_path;
const chunk = await fetchChunkIntoCache(chunkPath, loadToken, { announce: true });
const chunkSteps = chunk.timeline_steps || [];
const targetEpoch = Number(summaryStep.epoch ?? summaryStep.global_step ?? index);
const fullStep = chunkSteps.find(
(s) => Number(s.epoch ?? s.global_step) === targetEpoch
);
if (!fullStep) {
throw new Error(`Epoch ${targetEpoch} missing from chunk ${chunkPath}`);
}
return { ...fullStep, chunk_path: chunkPath };
}
function renderResultsTable(runs, selectedRunId) {
if (!resultsTableBody) return;
resultsTableBody.innerHTML = "";
runs.forEach((run) => {
const row = document.createElement("tr");
row.dataset.runId = run.id;
if (run.id === selectedRunId) row.classList.add("is-selected");
const values = [
{ text: formatAcc(run.best_test_acc ?? run.final_test_acc), className: "metric-cell metric-cell-strong" },
{ text: formatInteger(run.num_qubits) },
{ text: formatInteger(run.num_layers_explicit ?? run.num_layers) },
{ text: formatInteger(run.num_layers_reuploading ?? run.num_layers) },
{ text: (run.methods ?? [run.method]).filter(Boolean).join(", ") || "β€”" },
{ text: (run.datasets ?? [run.dataset]).filter(Boolean).join(" + ") || "β€”" },
{ text: run.label || run.id, className: "run-cell" },
];
values.forEach(({ text, className }) => {
const cell = document.createElement("td");
cell.textContent = text;
if (className) cell.className = className;
row.appendChild(cell);
});
row.addEventListener("click", async () => {
runSelect.value = run.id;
await applyRun(run.id);
});
resultsTableBody.appendChild(row);
});
}
function updateAccPills(step) {
METHODS.forEach((method, mi) => {
DATASETS.forEach((dataset, di) => {
const pill = accPills[mi]?.[di];
if (!pill) return;
const acc = step?.accuracies?.[method]?.[dataset] ?? null;
pill.textContent = formatAcc(acc);
});
});
}
// ── step rendering ─────────────────────────────────────────────────────────────
async function refreshStepState(data, index, stepToken) {
const steps = data.timeline_steps || [];
if (!steps.length) {
renderEmptyState();
return;
}
// Render summary metrics immediately (no chunk needed)
const summaryStep = steps[index];
if (currentStepLabel) currentStepLabel.textContent = summaryStep.label || `Epoch ${summaryStep.epoch ?? index}`;
if (timelineCaption) timelineCaption.hidden = true;
if (trainAccPill) {
trainAccPill.textContent = `Train ${formatAcc(summaryStep.train_acc)}`;
trainAccPill.hidden = false;
}
if (testAccPill) {
testAccPill.textContent = `Test ${formatAcc(summaryStep.test_acc)}`;
testAccPill.hidden = false;
}
renderAccuracyChart(steps, index);
updateAccPills(summaryStep);
// Resolve full step payload (may require a chunk fetch)
const step = await resolveStepPayload(data, index, state.activeLoadToken);
if (stepToken !== state.activeStepToken) return;
if (!step?.boundaries) {
// Chunked data not yet available β€” leave boundary panels as-is
return;
}
// Scatter lives at top level (fixed) or falls back to per-step copy
const scatterSource = data.scatter ?? {};
// Decision boundaries (3D surface)
METHODS.forEach((method, mi) => {
DATASETS.forEach((dataset, di) => {
const container = boundaryPlots[mi]?.[di];
if (!container) return;
const heatmap = step.boundaries?.[method]?.[dataset] ?? null;
const points = scatterSource[dataset] ?? step.scatter?.[dataset] ?? null;
renderBoundarySurface(container, heatmap, points, method, state.cameraState[di]);
});
});
// Attach camera-sync listeners once per panel (idempotent)
DATASETS.forEach((_, di) => bindCameraSync(di));
if (chartEmpty) chartEmpty.hidden = true;
if (lossChart) lossChart.style.display = "";
setLoadingState({ visible: false });
}
// ── run loading ────────────────────────────────────────────────────────────────
function populateExperimentMeta(data, run) {
if (!experimentMeta) return;
experimentMeta.innerHTML = "";
appendMetaRow("Model", data.experiment?.model);
appendMetaRow("Task", data.experiment?.task);
appendMetaRow("Methods", (data.experiment?.methods ?? []).join(", "));
appendMetaRow("Datasets", (data.experiment?.datasets ?? []).join(", "));
appendMetaRow("Device", data.experiment?.device);
if (run?.num_qubits !== undefined) appendMetaRow("Qubits", run.num_qubits);
if (run?.num_layers_explicit !== undefined || run?.num_layers_reuploading !== undefined) {
appendMetaRow(
"Layers (explicit / reupload)",
`${formatInteger(run?.num_layers_explicit)} / ${formatInteger(run?.num_layers_reuploading)}`,
);
} else if (run?.num_layers !== undefined) {
appendMetaRow("Layers", run.num_layers);
}
if (run?.num_params !== undefined) appendMetaRow("Params", formatInteger(run.num_params));
if (run?.train_time !== undefined) appendMetaRow("Train time", run.train_time);
appendMetaRow("Note", data.experiment?.note);
}
async function applyRun(runId) {
state.currentRunId = runId;
state.currentRunChunkCache = {};
state.currentRunChunkInflight = {};
const loadToken = ++state.activeLoadToken;
const selectedRun =
state.currentManifest.runs.find((r) => r.id === runId) ||
state.currentManifest.runs[0];
setLoadingState({
visible: true,
label: `Preparing ${selectedRun.label}`,
percent: 20,
status: "loading",
});
state.currentData = await loadRunData(selectedRun.path, loadToken);
if (loadToken !== state.activeLoadToken) return;
setLoadingState({ visible: true, label: `Rendering ${selectedRun.label}`, percent: 82, status: "rendering" });
pageTitle.textContent = state.currentData.title;
pageSubtitle.textContent = state.currentData.subtitle;
exportStatus.textContent = state.currentData.status;
runNote.textContent = `Loaded ${selectedRun.label} with ${selectedRun.steps} exported steps.`;
renderResultsTable(state.currentManifest.runs, selectedRun.id);
if (datasetsImage) {
const src = state.currentData.assets?.datasets_overview;
if (src) {
datasetsImage.src = withCacheBust(src);
} else {
datasetsImage.removeAttribute("src");
}
}
populateExperimentMeta(state.currentData, selectedRun);
const steps = state.currentData.timeline_steps || [];
if (steps.length > 1) {
stepSlider.disabled = false;
stepSlider.min = "0";
stepSlider.max = String(steps.length - 1);
stepSlider.value = "0";
} else {
stepSlider.disabled = true;
stepSlider.min = stepSlider.max = stepSlider.value = "0";
}
await refreshStepState(state.currentData, 0, ++state.activeStepToken);
prefetchChunkStream(state.currentData, loadToken, ++state.activePrefetchToken);
if (!getChunkPaths(state.currentData).length) {
setLoadingState({ visible: false, label: "Viewer ready", percent: 100, status: "ready" });
}
}
// ── camera sync ───────────────────────────────────────────────────────────────
/**
* Bind plotly_relayout camera-sync for one dataset row.
* Safe to call multiple times β€” skips panels already bound.
* @param {number} di Dataset index (0=circle, 1=moons)
*/
function bindCameraSync(di) {
const syncTimers = {};
METHODS.forEach((_, mi) => {
const container = boundaryPlots[mi]?.[di];
if (!container || container._cameraBound) return;
container._cameraBound = true;
container.on("plotly_relayout", (eventData) => {
if (container._receivingSync) return;
const cam = eventData["scene.camera"];
if (!cam) return;
state.cameraState[di] = cam;
clearTimeout(syncTimers[mi]);
syncTimers[mi] = setTimeout(() => {
METHODS.forEach((_, otherMi) => {
if (otherMi === mi) return;
const other = boundaryPlots[otherMi]?.[di];
if (!other?._hasPlot) return;
other._receivingSync = true;
Plotly.relayout(other, { "scene.camera": cam });
setTimeout(() => { other._receivingSync = false; }, 300);
});
}, 150);
});
});
}
// ── main ───────────────────────────────────────────────────────────────────────
async function main() {
bindImageLightbox();
bindAnalysisModal();
maybeShowAnalysisHint();
// Seed per-dataset home cameras before first render
state.cameraState[1] = MOONS_DEFAULT_CAMERA;
setLoadingState({ visible: true, label: "Booting viewer", percent: 2, status: "loading" });
await loadRuntimeSource();
state.currentManifest = await loadManifest();
const runs = state.currentManifest.runs || [];
runSelect.innerHTML = "";
runs.forEach((run) => {
const opt = document.createElement("option");
opt.value = run.id;
opt.textContent = run.label;
runSelect.appendChild(opt);
});
if (!runs.length) throw new Error("Viewer manifest contains no runs.");
const defaultRunId = state.currentManifest.default_run || runs[0].id;
runSelect.value = defaultRunId;
renderResultsTable(runs, defaultRunId);
await applyRun(defaultRunId);
runSelect.addEventListener("change", async (e) => applyRun(e.target.value));
// Camera preset buttons
const applyPresetCamera = (di, camera) => {
state.cameraState[di] = camera;
METHODS.forEach((_, mi) => {
const container = boundaryPlots[mi]?.[di];
if (!container?._hasPlot) return;
container._receivingSync = true;
Plotly.relayout(container, { "scene.camera": camera, "scene.dragmode": "turntable" });
setTimeout(() => { container._receivingSync = false; }, 300);
});
};
const homecameras = [DEFAULT_CAMERA, MOONS_DEFAULT_CAMERA];
homeButtons.forEach((btn, di) => {
if (!btn) return;
btn.addEventListener("click", () => applyPresetCamera(di, homecameras[di]));
});
const topcameras = [TOP_CAMERA, MOONS_TOP_CAMERA];
topButtons.forEach((btn, di) => {
if (!btn) return;
btn.addEventListener("click", () => applyPresetCamera(di, topcameras[di]));
});
stepSlider.addEventListener("input", async (e) => {
if (state.currentData) {
await refreshStepState(
state.currentData,
Number(e.target.value),
++state.activeStepToken,
);
}
});
}
main().catch((error) => {
console.error(error);
pageSubtitle.textContent = "Failed to load static export.";
runNote.textContent = "Unable to load a viewer manifest.";
if (chartEmpty) {
chartEmpty.textContent = "The static viewer failed to load its export data.";
chartEmpty.hidden = false;
chartEmpty.style.display = "flex";
}
setLoadingState({ visible: true, label: "Load failed", percent: 100, status: "error" });
});