Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ entry per wave, newest first; the first six waves (V1–V6, the MVP) predate the
are recorded in PLAN.md §B and §J. Each entry carries the opening sentence of its row and
the reason the wave was built.

## V48 — A damaged share link ends on a refusal, never on a crash

The plan for this row was a deeper validator: check `insights.importance`, numeric metrics, `classes` and `confusion`, the fields the audit had named.

_Why:_ Approved 09/10/2026 after V47. A link is input from a stranger, and the most common stranger is a chat client that cut it. The decoder already refused the shapes the V35 audit had found; the measurement showed it could not keep up with the format, and that the robust answer was at the point of failure rather than at the gate.

## V47 — Workers that cannot answer the wrong question

Six lifecycle defects in shipped code, each reproduced by a test that failed before its fix.
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ Wikimedia Commons), the portrait is NASA, public domain._
a doc page carries its whole article, in English until the app mounts); Lighthouse
mobile ≈ 0.99 on `/ml` under real throttling. Heavy dependencies (Dexie, SheetJS, ONNX
Runtime) load lazily.
- **Quality bar.** 883 unit tests and 123 Playwright end-to-end tests across five
- **Quality bar.** 889 unit tests and 123 Playwright end-to-end tests across five
projects — desktop, a phone viewport in English and in French, dark mode, and
Cloudflare Pages' own routing emulated by `wrangler pages dev` (a real 404, the security
headers as served) — covering offline PWA, a fake webcam, a horizontal-overflow guard on
Expand Down
58 changes: 58 additions & 0 deletions docs/superpowers/plans/2026-10-09-v48-share-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# V48: a damaged share link ends on a refusal, never on a crash

## Why

The post-V46 audit found that `isRenderable` checked `insights` was an object
and `RunView` then called `insights.importance.slice`: a link carrying
`insights: {}` passed the gate and crashed the page. The plan row proposed
validating that field and a few others.

## Measure first

Three real share links were captured from the running app and decoded:

| Link | Run | Analyses carried |
| ------- | ------------------- | ------------------------------------------------ |
| titanic | survived, binary | uncertainty, threshold, segments |
| iris | species, multiclass | uncertainty, threshold, k-NN tuning, exploration |
| mpg | mpg, regression | uncertainty, segments |

Every field (first two items of each array) was deleted, nulled, or replaced by
a string, a number, an object or an array, and each result was rendered
through the real `SharedRunPage`:

| Damaged links | Accepted by the decoder | Crashed the page | Distinct paths |
| ------------- | ----------------------- | ---------------- | -------------- |
| 3 948 | 3 660 | 1 020 | 99 |

The paths run from `insights.importance` to
`artifacts.exploration.clusters[].traits[].clusterMean`. A validator covering
them would be the largest file in the share module and would drift every time
a run learns to carry a new analysis.

## Decision

- The decoder keeps checking what every view reads first (V35).
- `SharedRunPage` wraps the run in a render guard. Any failure while drawing it
shows the refusal page with its own explanation, and nothing from the run.
Showing the readable half was rejected: a half-read run lets a damaged figure
pass for a real one.
- The measurement becomes a permanent test over the committed links
(`tests/share-links/`). Each field shape is damaged six ways once, in the
first link that carries it. It fails on the code before this wave.

Cost: the test adds about 25 s to the unit suite (66 s to 90 s on four cores).
Sampling fewer shapes would have been faster and would have reopened the gap
the wave closes.

## Also in this wave

The five copies of the download helper become `src/lib/download.ts`. The
object URL is revoked after the click has been handled rather than in the same
tick, where some engines cancel the download without a word.

## What this wave deliberately does not do

Write a field-by-field schema for every artifact, render a partially readable
run, or guard event handlers: a click on a damaged run is not reachable because
the refusal replaces it first.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "labml",
"private": true,
"version": "1.47.0",
"version": "1.48.0",
"license": "MIT",
"type": "module",
"engines": {
Expand Down
9 changes: 2 additions & 7 deletions src/features/data/SqlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useDataStore } from '@/features/data/data-store';
import { BUNDLE_BYTES, type SqlEngine } from '@/features/data/sql/engine';
import { readerFor, tableNameFor, tableToCsv, type SqlTable } from '@/features/data/sql/table';
import { ScrollRegion } from '@/components/ui/scroll-region';
import { downloadFile } from '@/lib/download';

/** Rows painted at once. The count above the table always tells the truth. */
const DISPLAY_CAP = 200;
Expand Down Expand Up @@ -141,13 +142,7 @@ export function SqlPanel() {

const download = useCallback(() => {
if (!result) return;
const blob = new Blob([tableToCsv(result)], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'query.csv';
anchor.click();
URL.revokeObjectURL(url);
downloadFile('query.csv', tableToCsv(result), 'text/csv');
}, [result]);

const toLab = useCallback(() => {
Expand Down
12 changes: 2 additions & 10 deletions src/features/data/components/DataExportBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,7 @@ import { useNavigate } from 'react-router';
import { Button } from '@/components/ui/button';
import { useDataStore } from '@/features/data/data-store';
import { useLabStore } from '@/features/ml/lab-store';

function downloadFile(name: string, mime: string, content: string) {
const url = URL.createObjectURL(new Blob([content], { type: mime }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
import { downloadFile } from '@/lib/download';

/** Exports of the cleaned dataset — generated locally, like everything else. */
export function DataExportBar() {
Expand All @@ -30,7 +22,7 @@ export function DataExportBar() {

useEffect(() => {
if (!exportedFile) return;
downloadFile(exportedFile.name, exportedFile.mime, exportedFile.content);
downloadFile(exportedFile.name, exportedFile.content, exportedFile.mime);
clearExportedFile();
}, [exportedFile, clearExportedFile]);

Expand Down
16 changes: 6 additions & 10 deletions src/features/ml/components/BatchScorePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,7 @@ import { Eyebrow } from '@/components/ui/eyebrow';
import { useLabStore } from '@/features/ml/lab-store';
import { METRIC_ROWS, metricDelta } from '@/features/ml/train/score-view';
import { ScrollRegion } from '@/components/ui/scroll-region';

function downloadCsv(name: string, content: string) {
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
import { downloadFile } from '@/lib/download';

/** The production gesture: score a NEW file with the inspected model. */
export function BatchScorePanel() {
Expand Down Expand Up @@ -177,7 +169,11 @@ export function BatchScorePanel() {
variant="outline"
size="sm"
onClick={() =>
downloadCsv(result.fileName.replace(/\.[a-z]+$/i, '') + '-scored.csv', result.csv)
downloadFile(
result.fileName.replace(/\.[a-z]+$/i, '') + '-scored.csv',
result.csv,
'text/csv',
)
}
>
{t('ml.lab.batch.downloadCsv')}
Expand Down
14 changes: 3 additions & 11 deletions src/features/ml/components/ExportBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,7 @@ import { Button } from '@/components/ui/button';
import { buildReportHtml } from '@/features/ml/projects/report';
import { encodeShareFragment } from '@/features/ml/projects/share';
import { useLabStore } from '@/features/ml/lab-store';

function downloadFile(name: string, mime: string, content: string) {
const url = URL.createObjectURL(new Blob([content], { type: mime }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
import { downloadFile } from '@/lib/download';

/** Export & share actions for the completed run — everything generated locally. */
export function ExportBar() {
Expand All @@ -28,7 +20,7 @@ export function ExportBar() {

useEffect(() => {
if (!exportedFile) return;
downloadFile(exportedFile.name, exportedFile.mime, exportedFile.content);
downloadFile(exportedFile.name, exportedFile.content, exportedFile.mime);
clearExportedFile();
}, [exportedFile, clearExportedFile]);

Expand All @@ -45,8 +37,8 @@ export function ExportBar() {
if (!currentRun) return;
downloadFile(
`labml-${currentRun.dataset.name.replace(/\.[a-z]+$/i, '')}-report.html`,
'text/html',
buildReportHtml(currentRun, t, lang),
'text/html',
);
}

Expand Down
14 changes: 4 additions & 10 deletions src/features/ml/components/ImportModelPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,7 @@ import { Eyebrow } from '@/components/ui/eyebrow';
import { METRIC_ROWS, metricDelta } from '@/features/ml/train/score-view';
import { useLabStore } from '@/features/ml/lab-store';
import { cn } from '@/lib/utils';

function downloadCsv(content: string, name: string) {
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
import { downloadFile } from '@/lib/download';

/** Maps a named worker refusal to its translation key (suffixes stripped). */
function errorKey(message: string): { key: string; detail?: string } {
Expand Down Expand Up @@ -217,7 +209,9 @@ export function ImportModelPanel() {
<Button
variant="outline"
size="sm"
onClick={() => downloadCsv(result.csv, `labml-imported-${result.fileName}`)}
onClick={() =>
downloadFile(`labml-imported-${result.fileName}`, result.csv, 'text/csv')
}
>
{t('ml.lab.batch.downloadCsv')}
</Button>
Expand Down
119 changes: 119 additions & 0 deletions src/features/ml/pages/SharedRunPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { cleanup, render, screen } from '@testing-library/react';
import { compressToEncodedURIComponent } from 'lz-string';
import { MemoryRouter } from 'react-router';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import iris from '../../../../tests/share-links/iris.json';
import mpg from '../../../../tests/share-links/mpg.json';
import titanic from '../../../../tests/share-links/titanic.json';
import { SharedRunPage } from '@/features/ml/pages/SharedRunPage';
import i18n from '@/lib/i18n';

/**
* V48 — a share link is input from a stranger, and the page must end on the
* run or on a refusal, never on a crash. Before V48, deleting, nulling or
* retyping single fields of three real links produced 3 660 links the decoder
* accepted, and 1 020 of them crashed the page on 99 distinct paths.
*/
const LINKS = { titanic, iris, mpg } as const;

type Path = (string | number)[];

const MUTATIONS: [string, unknown][] = [
['delete', undefined],
['null', null],
['string', 'x'],
['number', 1],
['object', {}],
['array', []],
];

/** Every field of the payload, through the first item of each array. */
function paths(node: unknown, prefix: Path = [], out: Path[] = []): Path[] {
if (prefix.length > 0) out.push(prefix);
if (Array.isArray(node)) node.slice(0, 1).forEach((v, i) => paths(v, [...prefix, i], out));
else if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node)) paths(value, [...prefix, key], out);
}
return out;
}

function mutate(root: unknown, path: Path, kind: string, value: unknown): unknown {
const copy = structuredClone(root);
let node = copy as Record<string | number, unknown>;
for (const key of path.slice(0, -1)) node = node[key] as Record<string | number, unknown>;
const last = path[path.length - 1];
if (kind !== 'delete') node[last] = value;
else if (Array.isArray(node)) node.splice(last as number, 1);
else delete node[last];
return copy;
}

function openLink(payload: unknown) {
window.location.hash = compressToEncodedURIComponent(JSON.stringify(payload));
return render(
<MemoryRouter>
<SharedRunPage />
</MemoryRouter>,
);
}

beforeAll(async () => {
await i18n.changeLanguage('en');
});

beforeEach(() => {
// React reports every error a boundary catches; hundreds are expected here.
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

describe('SharedRunPage', () => {
it.each(Object.entries(LINKS))('renders the real %s link', (_name, payload) => {
openLink(payload);
expect(screen.getByTestId('run-view')).toBeInTheDocument();
});

it('refuses, with its own explanation, a link that decodes but cannot be drawn', () => {
// The audit's example: `insights: {}` passed the decoder, then
// `importance.slice` crashed the page.
openLink({ ...titanic, insights: {} });
expect(screen.queryByTestId('run-view')).not.toBeInTheDocument();
expect(screen.getByTestId('share-refused')).toHaveTextContent(
'does not have the shape LabML writes',
);
});

it('ends every damaged link on the run or on a refusal, never on a crash', () => {
// Each field shape is damaged once, in the first link that carries it:
// the three links share most of their structure, and rendering the same
// damage three times tests nothing new.
const seen = new Set<string>();
const crashes: string[] = [];
for (const [name, payload] of Object.entries(LINKS)) {
for (const path of paths(payload)) {
const shape = path.map((key) => (typeof key === 'number' ? '[]' : key)).join('.');
if (seen.has(shape)) continue;
seen.add(shape);
for (const [kind, value] of MUTATIONS) {
try {
openLink(mutate(payload, path, kind, value));
const shown =
screen.queryByTestId('run-view') !== null ||
screen.queryByTestId('share-refused') !== null;
if (!shown) crashes.push(`${name}: ${shape} (${kind}): blank page`);
} catch (error) {
crashes.push(`${name}: ${shape} (${kind}): ${(error as Error).message}`);
} finally {
cleanup();
}
}
}
}
expect(seen.size).toBeGreaterThan(100);
expect(crashes).toEqual([]);
}, 120_000);
});
Loading
Loading