Replace solver loss chart with state metrics

This commit is contained in:
Meta Vens 2026-04-29 18:20:10 +01:00
commit bac0b6c527
5 changed files with 13 additions and 470 deletions

View file

@ -142,7 +142,6 @@ type AppState = {
heartbeatIntervalSeconds: number;
staleAfterSeconds: number;
solverStepBudget: number;
lossAxisMax: number | null;
eventLog: LogItem[];
viewerInspectZoom: number;
paintColor: [number, number, number, number];
@ -180,7 +179,6 @@ const statusMetrics = byId<HTMLDivElement>("statusMetrics");
const inferenceSummary = byId<HTMLDivElement>("inferenceSummary");
const candidateList = byId<HTMLDivElement>("candidateList");
const eventLog = byId<HTMLDivElement>("eventLog");
const lossCanvas = byId<HTMLCanvasElement>("lossCanvas");
const leftCanvas = byId<HTMLCanvasElement>("leftCanvas");
const rightCanvas = byId<HTMLCanvasElement>("rightCanvas");
const leftVizLabel = byId<HTMLDivElement>("leftVizLabel");
@ -274,7 +272,6 @@ const state: AppState = {
heartbeatIntervalSeconds: 10,
staleAfterSeconds: 30,
solverStepBudget: 0,
lossAxisMax: null,
eventLog: [],
viewerInspectZoom: Number(inspectZoomInput.value),
paintColor: [255, 255, 255, 255],
@ -515,7 +512,6 @@ function resetRunArtifacts(options: { preserveSourceImage?: boolean } = {}): voi
state.queueDepth = 0;
state.waitingCount = 0;
state.solverStepBudget = 0;
state.lossAxisMax = null;
state.eventLog = [];
state.editorBaseAsset = null;
state.editorDirty = false;
@ -794,11 +790,11 @@ function renderStatusMetrics(): void {
}
} else if (state.stageKey === "solver") {
const solverTerms: StatusMetricItem[] = [
{ label: "Coherence", value: formatNumber(frame?.terms.local_coherence ?? null, 4) },
{ label: "Edge", value: formatNumber(frame?.terms.local_edge ?? null, 4) },
{ label: "Smoothness", value: formatNumber(frame?.terms.smoothness ?? null, 4) },
{ label: "Collapse", value: formatNumber(frame?.terms.collapse ?? null, 4) },
{ label: "Magnitude", value: formatNumber(frame?.terms.magnitude ?? null, 4) },
{ label: "Signal", value: formatNumber(frame?.terms.local_signal ?? null, 4) },
{ label: "Grid align", value: formatNumber(frame?.terms.grid_alignment ?? null, 4) },
{ label: "Mean move", value: formatNumber(frame?.solverMetrics.mean_displacement_px ?? null, 2) },
{ label: "Max dx", value: formatNumber(frame?.solverMetrics.max_abs_dx_px ?? null, 2) },
{ label: "Max dy", value: formatNumber(frame?.solverMetrics.max_abs_dy_px ?? null, 2) },
];
items.push({
label: "Grid",
@ -814,7 +810,6 @@ function renderStatusMetrics(): void {
? `${formatNumber(state.phaseFieldPrep.cellX, 1)}x${formatNumber(state.phaseFieldPrep.cellY, 1)} px`
: "pending",
});
items.push({ label: "Loss", value: frame?.loss === null || frame?.loss === undefined ? "n/a" : formatNumber(frame.loss, 4) });
items.push(...solverTerms);
}
renderStatusMetricItems(items);
@ -901,221 +896,6 @@ async function renderViewer(): Promise<void> {
]);
}
function drawWrappedCanvasText(
context: CanvasRenderingContext2D,
text: string,
options: { x: number; y: number; maxWidth: number; lineHeight: number },
): void {
const { x, y, maxWidth, lineHeight } = options;
const words = text.split(/\s+/);
let line = "";
let cursorY = y;
for (const word of words) {
const nextLine = line ? `${line} ${word}` : word;
if (line && context.measureText(nextLine).width > maxWidth) {
context.fillText(line, x, cursorY);
line = word;
cursorY += lineHeight;
continue;
}
line = nextLine;
}
if (line) {
context.fillText(line, x, cursorY);
}
}
function renderLossPlaceholder(context: CanvasRenderingContext2D, width: number, heading: string, detail: string): void {
context.textBaseline = "top";
context.fontKerning = "none";
context.fillStyle = "rgba(255, 216, 74, 0.9)";
context.font = "12px 'Press Start 2P', monospace";
context.fillText(heading.toUpperCase(), 20, 14);
context.fillStyle = "rgba(255,255,255,0.42)";
context.font = "20px 'VT323', monospace";
drawWrappedCanvasText(context, detail, { x: 20, y: 38, maxWidth: width - 40, lineHeight: 21 });
}
function getLossDomainMax(): number {
const frameDomain = state.frames.reduce((best, frame) => Math.max(best, frame.totalSteps), 0);
if (state.solverStepBudget > 0) {
return Math.max(1, state.solverStepBudget);
}
return Math.max(1, frameDomain);
}
function buildLossSamples(): Array<{ step: number; loss: number }> {
return state.frames
.filter((frame) => typeof frame.loss === "number" && Number.isFinite(frame.loss))
.map((frame) => ({ step: frame.step, loss: frame.loss as number }))
.sort((left, right) => left.step - right.step);
}
function formatTickLabel(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
function buildStepTicks(domainMax: number): number[] {
if (domainMax <= 1) {
return [0, domainMax];
}
const quarter = domainMax / 4;
const ticks = [0, quarter, quarter * 2, quarter * 3, domainMax];
const rounded = ticks.map((tick, index) => (index === 0 || index === ticks.length - 1 ? tick : Math.round(tick)));
return rounded.filter((tick, index) => rounded.findIndex((candidate) => Math.abs(candidate - tick) < 1e-6) === index);
}
function lossCanvasScale(): number {
return Math.max(1, Math.ceil(window.devicePixelRatio || 1));
}
function configureLossCanvas(): { context: CanvasRenderingContext2D; width: number; height: number } | null {
const context = lossCanvas.getContext("2d");
if (!context) {
return null;
}
const rect = lossCanvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width <= 0 || height <= 0) {
return null;
}
const scale = lossCanvasScale();
const pixelWidth = width * scale;
const pixelHeight = height * scale;
if (lossCanvas.width !== pixelWidth || lossCanvas.height !== pixelHeight) {
lossCanvas.width = pixelWidth;
lossCanvas.height = pixelHeight;
}
context.setTransform(scale, 0, 0, scale, 0, 0);
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
return { context, width, height };
}
function snapStroke(value: number): number {
return Math.round(value) + 0.5;
}
function snapText(value: number): number {
return Math.round(value);
}
function renderLossChart(): void {
lossCanvas.hidden = state.stageKey !== "solver";
if (lossCanvas.hidden) {
return;
}
const configuredCanvas = configureLossCanvas();
if (!configuredCanvas) {
return;
}
const { context, width, height } = configuredCanvas;
context.fontKerning = "none";
context.fillStyle = "rgba(7, 15, 17, 0.96)";
context.fillRect(0, 0, width, height);
const domainMax = getLossDomainMax();
const samples = buildLossSamples();
const observedMaxLoss = samples.length === 0 ? null : Math.max(...samples.map((sample) => sample.loss));
if (observedMaxLoss !== null) {
state.lossAxisMax = Math.max(state.lossAxisMax ?? 0, observedMaxLoss);
}
const axisMaxLoss = Math.max(0.001, state.lossAxisMax ?? observedMaxLoss ?? 1);
const maxLoss = axisMaxLoss * 1.05;
const minLoss = 0;
const chartLeft = 88;
const chartRight = width - 18;
const chartTop = 16;
const chartBottom = height - 42;
const chartWidth = chartRight - chartLeft;
const chartHeight = chartBottom - chartTop;
const span = Math.max(1e-6, maxLoss - minLoss);
const yTicks = 4;
context.strokeStyle = "rgba(255,255,255,0.08)";
context.lineWidth = 1;
context.font = "16px 'VT323', monospace";
context.fillStyle = "rgba(255,255,255,0.58)";
context.textAlign = "right";
context.textBaseline = "middle";
for (let index = 0; index <= yTicks; index += 1) {
const ratio = index / yTicks;
const y = snapStroke(chartTop + ratio * chartHeight);
context.beginPath();
context.moveTo(chartLeft, y);
context.lineTo(chartRight, y);
context.stroke();
const tickValue = maxLoss - ratio * span;
context.fillText(formatNumber(tickValue, 4), chartLeft - 10, snapText(y));
}
const stepTicks = buildStepTicks(Math.max(1, domainMax));
context.textAlign = "center";
context.textBaseline = "top";
for (const tick of stepTicks) {
const x = snapStroke(chartLeft + (tick / Math.max(1, domainMax)) * chartWidth);
context.beginPath();
context.moveTo(x, chartTop);
context.lineTo(x, chartBottom);
context.stroke();
context.fillText(formatTickLabel(tick), snapText(x), chartBottom + 8);
}
context.fillStyle = "rgba(255, 216, 74, 0.9)";
context.font = "12px 'Press Start 2P', monospace";
context.fillText(`SOLVER STEP (0-${domainMax})`, snapText(chartLeft + chartWidth * 0.5), height - 20);
context.save();
context.translate(26, snapText(chartTop + chartHeight * 0.5));
context.rotate(-Math.PI / 2);
context.fillText("LOSS", 0, 0);
context.restore();
context.strokeStyle = "rgba(255, 216, 74, 0.18)";
context.lineWidth = 1.5;
context.strokeRect(snapStroke(chartLeft), snapStroke(chartTop), Math.round(chartWidth), Math.round(chartHeight));
context.strokeStyle = "rgba(255, 216, 74, 0.42)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(snapStroke(chartLeft), snapStroke(chartTop));
context.lineTo(snapStroke(chartLeft), snapStroke(chartBottom));
context.lineTo(snapStroke(chartRight), snapStroke(chartBottom));
context.stroke();
if (samples.length === 0) {
renderLossPlaceholder(
context,
width,
"Phase-field solve",
state.solverStepBudget <= 0
? "Solver steps were pinned to zero, so there is no loss walk to chart."
: "Loss samples land here once the solver starts moving.",
);
return;
}
context.strokeStyle = "#8fe2c8";
context.lineWidth = 3;
context.beginPath();
samples.forEach((sample, index) => {
const x = chartLeft + (sample.step / Math.max(1, domainMax)) * chartWidth;
const y = chartBottom - ((sample.loss - minLoss) / span) * chartHeight;
if (index === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
});
context.stroke();
const selected = getSelectedFrame();
if (selected && selected.loss !== null) {
const matchingIndex = samples.findIndex((sample) => sample.step === selected.step);
if (matchingIndex >= 0) {
const sample = samples[matchingIndex];
const x = chartLeft + (sample.step / Math.max(1, domainMax)) * chartWidth;
const y = chartBottom - (((selected.loss as number) - minLoss) / span) * chartHeight;
context.fillStyle = "#f5b76b";
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
}
}
}
async function loadEditorAsset(asset: ImageAsset | null): Promise<void> {
state.editorBaseAsset = asset;
state.editorDirty = false;
@ -1506,7 +1286,6 @@ async function renderEverything(): Promise<void> {
renderQueueSummary();
renderInference();
renderStatusMetrics();
renderLossChart();
await renderViewer();
}
@ -1717,7 +1496,7 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
"Phase-field solve",
frame.totalSteps <= 0
? "Initial placement committed. No iterative solver steps were requested."
: `Solver step ${frame.step} / ${frame.totalSteps}. Loss and drift terms stay live in this panel while it walks.`,
: `Solver step ${frame.step} / ${frame.totalSteps}. Signal, grid alignment, and displacement terms stay live in this panel while it walks.`,
);
if (eventName === "phase_field_final") {
addLog("Solver", "Final nearest-input sample committed.");
@ -2051,19 +1830,6 @@ downloadButton.addEventListener("click", () => {
exportEditorPng();
});
const lossCanvasResizeObserver = new ResizeObserver(() => {
renderLossChart();
});
lossCanvasResizeObserver.observe(lossCanvas);
void document.fonts.ready.then(() => {
renderLossChart();
});
window.addEventListener("resize", () => {
renderLossChart();
});
setPaintColor(state.paintColor);
syncEditorCursorState();
syncViewerInspectCursor();

View file

@ -20,7 +20,6 @@ const statusMetrics = byId("statusMetrics");
const inferenceSummary = byId("inferenceSummary");
const candidateList = byId("candidateList");
const eventLog = byId("eventLog");
const lossCanvas = byId("lossCanvas");
const leftCanvas = byId("leftCanvas");
const rightCanvas = byId("rightCanvas");
const leftVizLabel = byId("leftVizLabel");
@ -110,7 +109,6 @@ const state = {
heartbeatIntervalSeconds: 10,
staleAfterSeconds: 30,
solverStepBudget: 0,
lossAxisMax: null,
eventLog: [],
viewerInspectZoom: Number(inspectZoomInput.value),
paintColor: [255, 255, 255, 255],
@ -335,7 +333,6 @@ function resetRunArtifacts(options = {}) {
state.queueDepth = 0;
state.waitingCount = 0;
state.solverStepBudget = 0;
state.lossAxisMax = null;
state.eventLog = [];
state.editorBaseAsset = null;
state.editorDirty = false;
@ -581,11 +578,11 @@ function renderStatusMetrics() {
}
else if (state.stageKey === "solver") {
const solverTerms = [
{ label: "Coherence", value: formatNumber(frame?.terms.local_coherence ?? null, 4) },
{ label: "Edge", value: formatNumber(frame?.terms.local_edge ?? null, 4) },
{ label: "Smoothness", value: formatNumber(frame?.terms.smoothness ?? null, 4) },
{ label: "Collapse", value: formatNumber(frame?.terms.collapse ?? null, 4) },
{ label: "Magnitude", value: formatNumber(frame?.terms.magnitude ?? null, 4) },
{ label: "Signal", value: formatNumber(frame?.terms.local_signal ?? null, 4) },
{ label: "Grid align", value: formatNumber(frame?.terms.grid_alignment ?? null, 4) },
{ label: "Mean move", value: formatNumber(frame?.solverMetrics.mean_displacement_px ?? null, 2) },
{ label: "Max dx", value: formatNumber(frame?.solverMetrics.max_abs_dx_px ?? null, 2) },
{ label: "Max dy", value: formatNumber(frame?.solverMetrics.max_abs_dy_px ?? null, 2) },
];
items.push({
label: "Grid",
@ -601,7 +598,6 @@ function renderStatusMetrics() {
? `${formatNumber(state.phaseFieldPrep.cellX, 1)}x${formatNumber(state.phaseFieldPrep.cellY, 1)} px`
: "pending",
});
items.push({ label: "Loss", value: frame?.loss === null || frame?.loss === undefined ? "n/a" : formatNumber(frame.loss, 4) });
items.push(...solverTerms);
}
renderStatusMetricItems(items);
@ -681,201 +677,6 @@ async function renderViewer() {
: drawAsset(rightCanvas, currentViewerPanels.right.asset),
]);
}
function drawWrappedCanvasText(context, text, options) {
const { x, y, maxWidth, lineHeight } = options;
const words = text.split(/\s+/);
let line = "";
let cursorY = y;
for (const word of words) {
const nextLine = line ? `${line} ${word}` : word;
if (line && context.measureText(nextLine).width > maxWidth) {
context.fillText(line, x, cursorY);
line = word;
cursorY += lineHeight;
continue;
}
line = nextLine;
}
if (line) {
context.fillText(line, x, cursorY);
}
}
function renderLossPlaceholder(context, width, heading, detail) {
context.textBaseline = "top";
context.fontKerning = "none";
context.fillStyle = "rgba(255, 216, 74, 0.9)";
context.font = "12px 'Press Start 2P', monospace";
context.fillText(heading.toUpperCase(), 20, 14);
context.fillStyle = "rgba(255,255,255,0.42)";
context.font = "20px 'VT323', monospace";
drawWrappedCanvasText(context, detail, { x: 20, y: 38, maxWidth: width - 40, lineHeight: 21 });
}
function getLossDomainMax() {
const frameDomain = state.frames.reduce((best, frame) => Math.max(best, frame.totalSteps), 0);
if (state.solverStepBudget > 0) {
return Math.max(1, state.solverStepBudget);
}
return Math.max(1, frameDomain);
}
function buildLossSamples() {
return state.frames
.filter((frame) => typeof frame.loss === "number" && Number.isFinite(frame.loss))
.map((frame) => ({ step: frame.step, loss: frame.loss }))
.sort((left, right) => left.step - right.step);
}
function formatTickLabel(value) {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
function buildStepTicks(domainMax) {
if (domainMax <= 1) {
return [0, domainMax];
}
const quarter = domainMax / 4;
const ticks = [0, quarter, quarter * 2, quarter * 3, domainMax];
const rounded = ticks.map((tick, index) => (index === 0 || index === ticks.length - 1 ? tick : Math.round(tick)));
return rounded.filter((tick, index) => rounded.findIndex((candidate) => Math.abs(candidate - tick) < 1e-6) === index);
}
function lossCanvasScale() {
return Math.max(1, Math.ceil(window.devicePixelRatio || 1));
}
function configureLossCanvas() {
const context = lossCanvas.getContext("2d");
if (!context) {
return null;
}
const rect = lossCanvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width <= 0 || height <= 0) {
return null;
}
const scale = lossCanvasScale();
const pixelWidth = width * scale;
const pixelHeight = height * scale;
if (lossCanvas.width !== pixelWidth || lossCanvas.height !== pixelHeight) {
lossCanvas.width = pixelWidth;
lossCanvas.height = pixelHeight;
}
context.setTransform(scale, 0, 0, scale, 0, 0);
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
return { context, width, height };
}
function snapStroke(value) {
return Math.round(value) + 0.5;
}
function snapText(value) {
return Math.round(value);
}
function renderLossChart() {
lossCanvas.hidden = state.stageKey !== "solver";
if (lossCanvas.hidden) {
return;
}
const configuredCanvas = configureLossCanvas();
if (!configuredCanvas) {
return;
}
const { context, width, height } = configuredCanvas;
context.fontKerning = "none";
context.fillStyle = "rgba(7, 15, 17, 0.96)";
context.fillRect(0, 0, width, height);
const domainMax = getLossDomainMax();
const samples = buildLossSamples();
const observedMaxLoss = samples.length === 0 ? null : Math.max(...samples.map((sample) => sample.loss));
if (observedMaxLoss !== null) {
state.lossAxisMax = Math.max(state.lossAxisMax ?? 0, observedMaxLoss);
}
const axisMaxLoss = Math.max(0.001, state.lossAxisMax ?? observedMaxLoss ?? 1);
const maxLoss = axisMaxLoss * 1.05;
const minLoss = 0;
const chartLeft = 88;
const chartRight = width - 18;
const chartTop = 16;
const chartBottom = height - 42;
const chartWidth = chartRight - chartLeft;
const chartHeight = chartBottom - chartTop;
const span = Math.max(1e-6, maxLoss - minLoss);
const yTicks = 4;
context.strokeStyle = "rgba(255,255,255,0.08)";
context.lineWidth = 1;
context.font = "16px 'VT323', monospace";
context.fillStyle = "rgba(255,255,255,0.58)";
context.textAlign = "right";
context.textBaseline = "middle";
for (let index = 0; index <= yTicks; index += 1) {
const ratio = index / yTicks;
const y = snapStroke(chartTop + ratio * chartHeight);
context.beginPath();
context.moveTo(chartLeft, y);
context.lineTo(chartRight, y);
context.stroke();
const tickValue = maxLoss - ratio * span;
context.fillText(formatNumber(tickValue, 4), chartLeft - 10, snapText(y));
}
const stepTicks = buildStepTicks(Math.max(1, domainMax));
context.textAlign = "center";
context.textBaseline = "top";
for (const tick of stepTicks) {
const x = snapStroke(chartLeft + (tick / Math.max(1, domainMax)) * chartWidth);
context.beginPath();
context.moveTo(x, chartTop);
context.lineTo(x, chartBottom);
context.stroke();
context.fillText(formatTickLabel(tick), snapText(x), chartBottom + 8);
}
context.fillStyle = "rgba(255, 216, 74, 0.9)";
context.font = "12px 'Press Start 2P', monospace";
context.fillText(`SOLVER STEP (0-${domainMax})`, snapText(chartLeft + chartWidth * 0.5), height - 20);
context.save();
context.translate(26, snapText(chartTop + chartHeight * 0.5));
context.rotate(-Math.PI / 2);
context.fillText("LOSS", 0, 0);
context.restore();
context.strokeStyle = "rgba(255, 216, 74, 0.18)";
context.lineWidth = 1.5;
context.strokeRect(snapStroke(chartLeft), snapStroke(chartTop), Math.round(chartWidth), Math.round(chartHeight));
context.strokeStyle = "rgba(255, 216, 74, 0.42)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(snapStroke(chartLeft), snapStroke(chartTop));
context.lineTo(snapStroke(chartLeft), snapStroke(chartBottom));
context.lineTo(snapStroke(chartRight), snapStroke(chartBottom));
context.stroke();
if (samples.length === 0) {
renderLossPlaceholder(context, width, "Phase-field solve", state.solverStepBudget <= 0
? "Solver steps were pinned to zero, so there is no loss walk to chart."
: "Loss samples land here once the solver starts moving.");
return;
}
context.strokeStyle = "#8fe2c8";
context.lineWidth = 3;
context.beginPath();
samples.forEach((sample, index) => {
const x = chartLeft + (sample.step / Math.max(1, domainMax)) * chartWidth;
const y = chartBottom - ((sample.loss - minLoss) / span) * chartHeight;
if (index === 0) {
context.moveTo(x, y);
}
else {
context.lineTo(x, y);
}
});
context.stroke();
const selected = getSelectedFrame();
if (selected && selected.loss !== null) {
const matchingIndex = samples.findIndex((sample) => sample.step === selected.step);
if (matchingIndex >= 0) {
const sample = samples[matchingIndex];
const x = chartLeft + (sample.step / Math.max(1, domainMax)) * chartWidth;
const y = chartBottom - ((selected.loss - minLoss) / span) * chartHeight;
context.fillStyle = "#f5b76b";
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
}
}
}
async function loadEditorAsset(asset) {
state.editorBaseAsset = asset;
state.editorDirty = false;
@ -1240,7 +1041,6 @@ async function renderEverything() {
renderQueueSummary();
renderInference();
renderStatusMetrics();
renderLossChart();
await renderViewer();
}
async function handleEvent(eventName, payload) {
@ -1413,7 +1213,7 @@ async function handleEvent(eventName, payload) {
}
setStage("solver", "Phase-field solve", frame.totalSteps <= 0
? "Initial placement committed. No iterative solver steps were requested."
: `Solver step ${frame.step} / ${frame.totalSteps}. Loss and drift terms stay live in this panel while it walks.`);
: `Solver step ${frame.step} / ${frame.totalSteps}. Signal, grid alignment, and displacement terms stay live in this panel while it walks.`);
if (eventName === "phase_field_final") {
addLog("Solver", "Final nearest-input sample committed.");
}
@ -1713,16 +1513,6 @@ resetEditorButton.addEventListener("click", () => {
downloadButton.addEventListener("click", () => {
exportEditorPng();
});
const lossCanvasResizeObserver = new ResizeObserver(() => {
renderLossChart();
});
lossCanvasResizeObserver.observe(lossCanvas);
void document.fonts.ready.then(() => {
renderLossChart();
});
window.addEventListener("resize", () => {
renderLossChart();
});
setPaintColor(state.paintColor);
syncEditorCursorState();
syncViewerInspectCursor();

View file

@ -64,7 +64,6 @@
<div id="statusMetrics" class="status-metrics"></div>
</div>
</div>
<canvas id="lossCanvas" class="loss-canvas ui-surface" width="800" height="148" hidden></canvas>
<div class="viewer-grid">
<figure class="viz-card ui-surface ui-frame">
<div class="viz-label" id="leftVizLabel">Input</div>

View file

@ -631,19 +631,6 @@ select.field-control optgroup {
color: var(--ui-text-bright);
}
.loss-canvas {
width: 100%;
height: 148px;
display: block;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent),
linear-gradient(180deg, #0b2138, #050f1c);
}
.loss-canvas[hidden] {
display: none;
}
.viewer-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View file

@ -23,3 +23,4 @@
{"ts": "2026-04-29T10:41:17+00:00", "type": "docs", "status": "accepted", "note": "Documentation hygiene rule accepted: project-facing docs should describe the live machine and current tradeoffs directly, not take victory laps about removed approaches. Historical contrast belongs only in evidence, changelogs, postmortems, or compact rejected-path notes when it changes future decisions."}
{"ts": "2026-04-29T12:24:23+00:00", "type": "hosted", "status": "accepted", "note": "Repixelizer now owns its Discord guild/role access policy through REPIXELIZER_ACCESS_DISCORD_* and sends it to Heimdall as a caller-owned discord_role_access entitlementPolicy during backend-callback OAuth start. Heimdall evaluates the supplied policy but no longer needs Repixelizer-specific role env."}
{"ts": "2026-04-29T13:37:17+00:00", "type": "hosted", "status": "accepted", "note": "Hosted Repixelizer now enables Patreon alongside Discord. Patreon access policy is title-only: REPIXELIZER_ACCESS_PATREON_TIER_TITLE=Inner Sanctum is sent to Heimdall as patreon_membership_access during backend-callback OAuth start."}
{"ts": "2026-04-29T17:20:00+00:00", "type": "implementation", "status": "ok", "note": "Removed the hosted solver loss chart because the explicit solver's loss-like diagnostic is not a useful live objective; solver status now surfaces step, signal, grid alignment, and displacement terms instead."}