Skip to content

Commit 4ba6c8d

Browse files
committed
fix bug where only train split was being presented. also better handle rate limits for remote datasets
1 parent 08e2828 commit 4ba6c8d

4 files changed

Lines changed: 205 additions & 43 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,17 @@ Two on-disk schemas carry the same information under different names. The rest o
3333

3434
1. **Welcome modal** — on every page load, the user picks a dataset before the app loads anything. Lists known HF datasets from `DATASETS` in [src/datasets.js](src/datasets.js), accepts a custom `user/dataset_name`, or accepts a local Arrow shard directory when self-hosting.
3535
2. **URL override**`?dataset=user/name` (optional `&config=`, `&split=`, `&label=`) prepends a synthetic descriptor and pre-selects it in the modal. Logic in `descriptorFromURL()` at the top of [src/App.jsx](src/App.jsx).
36+
37+
A descriptor's `split` may be a single name (`"train"`), an array, or `"*"` (every split, merged — see *The summary.json mechanism*). The custom-input and URL-override defaults are `"*"`, which gracefully reduces to whatever splits exist (a single-split dataset behaves exactly as before); pass `&split=train` to pin one. The bundled `ZTF_40k` entry uses `"*"` so all four splits (train/validation/test/anom, ~42.5k rows) load as one dataset.
3638
3. **File picker** — disabled in deployed builds via `IS_DEPLOYED`. **Brittleness**: this currently checks `import.meta.env.BASE_URL !== '/'`, which works for the github.io subpath deploy but would falsely re-enable the picker for a custom-domain build (`BASE_PATH=/`). Switch to a dedicated env var like `VITE_DEPLOY_TARGET=pages` set only in the workflow if adding a custom domain.
3739

3840
## The summary.json mechanism
3941

4042
`HFDataSource.getSummary()` fetches a pre-computed summary from `https://huggingface.co/datasets/<repo>/resolve/main/summary.<split>.json`, falling back to `summary.json` if that 404s. **Without it the app degrades**: no sky map, no class filter, no class-balanced random sampling. Falls back gracefully to global random offset.
4143

42-
**Per-split naming matters**: `classIndices` holds split-specific row offsets that `getRows({ offset })` relies on, so a `train`-built summary must not be served when viewing another split. Multi-split datasets need one file per split (`summary.<split>.json`); the `summary.json` fallback keeps existing single-split datasets working unchanged.
44+
**Per-split naming matters**: `classIndices` holds split-local row offsets that `getRows({ offset })` relies on, so a `train`-built summary must not be served when viewing another split. Multi-split datasets need one file per split (`summary.<split>.json`); the `summary.json` fallback keeps existing single-split datasets working unchanged.
45+
46+
**Merging splits into one view**: a descriptor's `split` may be a single name, an array, or `"*"` (every split in the config) — see *Dataset selection*. When more than one split is in play, `HFDataSource` treats them as one contiguous dataset: `getInfo` resolves the split list and computes per-split global base offsets (`_splitEnds`) from each split's `num_examples`; `getRows({ offset })` maps a global offset back to the owning split + local offset (`_splitForOffset`); and `getSummary` → `_mergeSummaries` fetches every `summary.<split>.json`, sums `classCounts`, concatenates `classIndices` **shifted by each split's global base** so they index the same concatenated space, unions `bands`, and re-runs `sampleSkyPointsByClass` over the pooled sky points (the per-split files are already sampled, so this is approximate but within the render budget). A split whose summary is missing is skipped — its rows stay reachable via global random sampling but won't appear in the sky map or class counts. This is purely client-side; the per-split files and `build_summary.py` are unchanged, so the base offsets only line up because each `summary.totalRows` equals that split's `num_examples`.
4347

4448
Why pre-computed: `HFDiskDataSource` builds the summary by scanning the entire dataset. Impossible for a multi-GB remote dataset, and HF's datasets-server doesn't expose per-row sky positions.
4549

src/App.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const IS_DEPLOYED = import.meta.env.BASE_URL !== '/';
6969
// Lets visitors point a deployed (e.g. GitHub Pages) build at any public HF
7070
// dataset via ?dataset=user/name (with optional ?config, ?split, ?label).
7171
// Returns null on missing or malformed input so the app falls back to DATASETS.
72+
// split defaults to "*" (every split, merged); pass ?split=train to pin one.
7273
function descriptorFromURL() {
7374
const p = new URLSearchParams(window.location.search);
7475
const dataset = p.get('dataset');
@@ -79,7 +80,7 @@ function descriptorFromURL() {
7980
source: 'hf',
8081
dataset,
8182
config: p.get('config') || 'default',
82-
split: p.get('split') || 'train',
83+
split: p.get('split') || '*',
8384
};
8485
}
8586

@@ -831,7 +832,7 @@ function WelcomeModal({ datasets, isDeployed, initialSelected, onConfirm, onCanc
831832
source: 'hf',
832833
dataset: trimmed,
833834
config: 'default',
834-
split: 'train',
835+
split: '*',
835836
});
836837
setFolderSelection(null);
837838
} else if (selected?.id?.startsWith?.('custom::')) {

src/data/HFDataSource.js

Lines changed: 194 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DataSource, detectFormat, normalizeRow } from "./DataSource.js";
2+
import { sampleSkyPointsByClass } from "./HFDiskDataSource.js";
23

34
/**
45
* Reads from the HuggingFace Datasets Server API.
@@ -10,9 +11,30 @@ import { DataSource, detectFormat, normalizeRow } from "./DataSource.js";
1011
*
1112
* Note: the /rows endpoint caps `length` at 100 per request. For more rows we
1213
* paginate.
14+
*
15+
* Multi-split: `split` may be a single name ("train"), an array of names, or
16+
* "*" (every split in the config). When more than one split is in play the
17+
* source presents them as one contiguous dataset — getRows maps a global offset
18+
* to the split that owns it, and getSummary merges the per-split summary files,
19+
* shifting each split's classIndices by its global base offset. Single-split
20+
* behaviour is unchanged.
1321
*/
1422
const BASE = "https://datasets-server.huggingface.co";
1523
const ROWS_PER_REQUEST = 100;
24+
const MAX_RETRIES = 3; // attempts after the first, for transient 429/5xx
25+
const RETRY_BASE_MS = 500; // exponential backoff base
26+
const RETRY_MAX_MS = 8000; // cap any single wait (incl. a large Retry-After)
27+
28+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29+
30+
// Parse a Retry-After header (delta-seconds or HTTP-date) into ms, or null.
31+
function retryAfterMs(res) {
32+
const h = res.headers.get("retry-after");
33+
if (!h) return null;
34+
if (/^\d+$/.test(h.trim())) return Number(h) * 1000;
35+
const t = Date.parse(h);
36+
return Number.isNaN(t) ? null : Math.max(0, t - Date.now());
37+
}
1638

1739
export class HFDataSource extends DataSource {
1840
constructor({ dataset, config = "default", split = "train" }) {
@@ -24,57 +46,117 @@ export class HFDataSource extends DataSource {
2446
}
2547
this.dataset = dataset;
2648
this.config = config;
27-
this.split = split;
49+
this.split = split; // "name" | ["a","b"] | "*" — resolved in getInfo
2850
this._infoCache = null;
2951
this._format = null;
3052
this._summaryCache = undefined; // distinct from null: caches the "no summary" answer too
53+
this._splits = null; // resolved split names, in global-offset order
54+
this._splitEnds = null; // cumulative row counts → global offset boundaries
55+
this._numRows = null; // total rows across all resolved splits
3156
}
3257

3358
_qs(params) {
3459
return new URLSearchParams(params).toString();
3560
}
3661

62+
// fetch that rides through transient rate-limit (429) and server (5xx)
63+
// responses with exponential backoff, honoring Retry-After when present.
64+
// Network errors (fetch rejecting) are retried too. Returns the final
65+
// Response for the caller to interpret (.ok / .status) — it never throws on
66+
// an HTTP status, so a single rate-limited request no longer aborts a load.
67+
async _fetchRetry(url) {
68+
for (let attempt = 0; ; attempt++) {
69+
let res;
70+
try {
71+
res = await fetch(url);
72+
} catch (e) {
73+
if (attempt >= MAX_RETRIES) throw e;
74+
await sleep(Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS));
75+
continue;
76+
}
77+
if ((res.status === 429 || res.status >= 500) && attempt < MAX_RETRIES) {
78+
const wait = retryAfterMs(res) ?? RETRY_BASE_MS * 2 ** attempt;
79+
await sleep(Math.min(wait, RETRY_MAX_MS));
80+
continue;
81+
}
82+
return res;
83+
}
84+
}
85+
3786
async getInfo() {
3887
if (this._infoCache) return this._infoCache;
3988

4089
const url = `${BASE}/info?${this._qs({ dataset: this.dataset })}`;
41-
const res = await fetch(url);
90+
const res = await this._fetchRetry(url);
4291
if (!res.ok) {
4392
throw new Error(`HF info request failed: ${res.status} ${res.statusText}`);
4493
}
4594
const data = await res.json();
4695
const cfg = data?.dataset_info?.[this.config];
47-
const splitInfo = cfg?.splits?.[this.split];
4896
const columns = cfg?.features ? Object.keys(cfg.features) : [];
4997
this._format = detectFormat(columns);
5098

99+
const available = cfg?.splits ? Object.keys(cfg.splits) : [];
100+
let splits;
101+
if (Array.isArray(this.split)) {
102+
splits = this.split.filter((s) => available.includes(s));
103+
} else if (this.split === "*") {
104+
splits = available;
105+
} else {
106+
splits = [this.split];
107+
}
108+
if (splits.length === 0) splits = [this.split].flat();
109+
this._splits = splits;
110+
111+
const counts = splits.map((s) => cfg?.splits?.[s]?.num_examples ?? 0);
112+
// A lone split keeps an open-ended boundary so a caller can request any
113+
// offset without us knowing num_examples up front (matches old behaviour).
114+
let acc = 0;
115+
this._splitEnds =
116+
splits.length === 1 ? [Infinity] : counts.map((n) => (acc += n));
117+
this._numRows =
118+
splits.length === 1
119+
? cfg?.splits?.[splits[0]]?.num_examples
120+
: counts.reduce((a, b) => a + b, 0);
121+
51122
this._infoCache = {
52123
source: "hf",
53124
dataset: this.dataset,
54125
config: this.config,
55-
split: this.split,
56-
numRows: splitInfo?.num_examples,
126+
split: splits.length === 1 ? splits[0] : splits,
127+
splits,
128+
numRows: this._numRows,
57129
columns,
58130
raw: data,
59131
};
60132
return this._infoCache;
61133
}
62134

135+
// Global offset → index of the split that owns it (-1 past the end).
136+
_splitForOffset(offset) {
137+
return this._splitEnds.findIndex((end) => end > offset);
138+
}
139+
63140
async getRows({ offset = 0, length = ROWS_PER_REQUEST } = {}) {
141+
await this.getInfo(); // resolves this._splits / this._splitEnds
64142
const collected = [];
65143
let remaining = length;
66-
let cursor = offset;
144+
let cursor = offset; // global offset across the concatenated splits
67145

68146
while (remaining > 0) {
69-
const take = Math.min(remaining, ROWS_PER_REQUEST);
147+
const si = this._splitForOffset(cursor);
148+
if (si === -1) break; // past the end of the last split
149+
const base = si === 0 ? 0 : this._splitEnds[si - 1];
150+
const room = this._splitEnds[si] - cursor; // rows left in this split
151+
const take = Math.min(remaining, room, ROWS_PER_REQUEST);
70152
const url = `${BASE}/rows?${this._qs({
71153
dataset: this.dataset,
72154
config: this.config,
73-
split: this.split,
74-
offset: String(cursor),
155+
split: this._splits[si],
156+
offset: String(cursor - base),
75157
length: String(take),
76158
})}`;
77-
const res = await fetch(url);
159+
const res = await this._fetchRetry(url);
78160
if (!res.ok) {
79161
throw new Error(`HF rows request failed: ${res.status} ${res.statusText}`);
80162
}
@@ -84,7 +166,7 @@ export class HFDataSource extends DataSource {
84166
collected.push(...rows);
85167
cursor += rows.length;
86168
remaining -= rows.length;
87-
if (rows.length < take) break; // end of split
169+
if (rows.length < take) break; // short read → end of this split's data
88170
}
89171

90172
return collected;
@@ -99,26 +181,28 @@ export class HFDataSource extends DataSource {
99181
if (!/^\d+$/.test(numeric)) {
100182
throw new Error("Source ID must be numeric");
101183
}
102-
await this.getInfo(); // ensure this._format is resolved
184+
await this.getInfo(); // resolves this._format and this._splits
103185
const idKey = this._format.idKey;
104186
let lastErr = null;
105-
for (const literal of [`'${numeric}'`, numeric]) {
106-
const url = `${BASE}/filter?${this._qs({
107-
dataset: this.dataset,
108-
config: this.config,
109-
split: this.split,
110-
where: `${idKey}=${literal}`,
111-
length: "1",
112-
})}`;
113-
const res = await fetch(url);
114-
if (!res.ok) {
115-
lastErr = `Search failed: ${res.status} ${res.statusText}`;
116-
continue;
187+
for (const split of this._splits) {
188+
for (const literal of [`'${numeric}'`, numeric]) {
189+
const url = `${BASE}/filter?${this._qs({
190+
dataset: this.dataset,
191+
config: this.config,
192+
split,
193+
where: `${idKey}=${literal}`,
194+
length: "1",
195+
})}`;
196+
const res = await this._fetchRetry(url);
197+
if (!res.ok) {
198+
lastErr = `Search failed: ${res.status} ${res.statusText}`;
199+
continue;
200+
}
201+
const data = await res.json();
202+
const rows = (data?.rows || []).map((r) => normalizeRow(r.row));
203+
if (rows[0]) return rows[0];
204+
lastErr = null;
117205
}
118-
const data = await res.json();
119-
const rows = (data?.rows || []).map((r) => normalizeRow(r.row));
120-
if (rows[0]) return rows[0];
121-
lastErr = null;
122206
}
123207
if (lastErr) throw new Error(lastErr);
124208
return null;
@@ -128,7 +212,7 @@ export class HFDataSource extends DataSource {
128212
// file is absent (404, so the caller can try a fallback), or null if present
129213
// but unusable (unsupported version).
130214
async _fetchSummary(url) {
131-
const res = await fetch(url);
215+
const res = await this._fetchRetry(url);
132216
if (res.status === 404) return undefined;
133217
if (!res.ok) {
134218
throw new Error(`summary fetch failed: ${res.status} ${res.statusText}`);
@@ -142,20 +226,26 @@ export class HFDataSource extends DataSource {
142226
}
143227

144228
// Fetches a pre-computed summary from the dataset repo. Build/upload via
145-
// scripts/build_summary.py. Multi-split datasets carry one file per split
146-
// (summary.<split>.json), since classIndices holds split-specific row offsets
147-
// that getRows({ offset }) relies on — serving another split's summary would
148-
// be wrong. Tries summary.<split>.json first, falling back to a plain
149-
// summary.json so single-split datasets keep working unchanged. Returns null
150-
// gracefully if absent so the app degrades to global random sampling without
151-
// sky map or class filter.
229+
// scripts/build_summary.py. Each split carries one file (summary.<split>.json)
230+
// because classIndices holds split-local row offsets. For a single split we
231+
// serve it directly (falling back to a plain summary.json so older
232+
// single-file datasets keep working). For multiple splits we merge them into
233+
// one unified summary (see _mergeSummaries). Returns null gracefully if absent
234+
// so the app degrades to global random sampling without sky map or class
235+
// filter.
152236
async getSummary() {
153237
if (this._summaryCache !== undefined) return this._summaryCache;
238+
await this.getInfo(); // resolves this._splits / this._splitEnds
154239
const base = `https://huggingface.co/datasets/${this.dataset}/resolve/main`;
155240
try {
156-
let data = await this._fetchSummary(`${base}/summary.${this.split}.json`);
157-
if (data === undefined) {
158-
data = await this._fetchSummary(`${base}/summary.json`);
241+
let data;
242+
if (this._splits.length === 1) {
243+
data = await this._fetchSummary(`${base}/summary.${this._splits[0]}.json`);
244+
if (data === undefined) {
245+
data = await this._fetchSummary(`${base}/summary.json`);
246+
}
247+
} else {
248+
data = await this._mergeSummaries(base);
159249
}
160250
this._summaryCache = data ?? null;
161251
return this._summaryCache;
@@ -165,4 +255,69 @@ export class HFDataSource extends DataSource {
165255
return null;
166256
}
167257
}
258+
259+
// Merges the per-split summary files into a single view. Each split's
260+
// classIndices are shifted by its global base offset (the cumulative row
261+
// count of the splits before it) so they index into the same concatenated
262+
// space getRows walks. A split whose summary is missing or unreadable is
263+
// skipped — its rows stay reachable through global random sampling, they just
264+
// won't appear on the sky map or in the class counts. Returns null only if no
265+
// split yielded a usable summary.
266+
async _mergeSummaries(base) {
267+
const perSplit = await Promise.all(
268+
this._splits.map((sp) =>
269+
this._fetchSummary(`${base}/summary.${sp}.json`).catch(() => undefined),
270+
),
271+
);
272+
273+
const classCounts = {};
274+
const classIndices = {};
275+
const bands = new Set();
276+
let skyByClass = new Map();
277+
let any = false;
278+
279+
this._splits.forEach((sp, i) => {
280+
const s = perSplit[i];
281+
if (!s) return;
282+
any = true;
283+
const splitBase = i === 0 ? 0 : this._splitEnds[i - 1];
284+
for (const b of s.bands ?? []) bands.add(b);
285+
for (const [cls, n] of Object.entries(s.classCounts ?? {})) {
286+
classCounts[cls] = (classCounts[cls] ?? 0) + n;
287+
}
288+
for (const [cls, idxs] of Object.entries(s.classIndices ?? {})) {
289+
const shifted = idxs.map((o) => o + splitBase);
290+
classIndices[cls] = (classIndices[cls] ?? []).concat(shifted);
291+
}
292+
for (const p of s.skyPoints ?? []) {
293+
if (!skyByClass.has(p.cls)) skyByClass.set(p.cls, []);
294+
skyByClass.get(p.cls).push({ ra: p.ra, dec: p.dec });
295+
}
296+
});
297+
298+
if (!any) return null;
299+
300+
// Sort by count descending to match the single-split convention.
301+
const sortedClasses = Object.keys(classCounts).sort(
302+
(a, b) => classCounts[b] - classCounts[a],
303+
);
304+
const sortedCounts = {};
305+
const sortedIndices = {};
306+
for (const c of sortedClasses) {
307+
sortedCounts[c] = classCounts[c];
308+
sortedIndices[c] = classIndices[c] ?? [];
309+
}
310+
311+
return {
312+
version: 1,
313+
totalRows: this._numRows,
314+
classCounts: sortedCounts,
315+
classIndices: sortedIndices,
316+
bands: [...bands],
317+
// Concatenating per-split sky samples can exceed the render budget, so
318+
// re-sample class-balanced over the merged points (approximate — the
319+
// inputs are already per-split samples — but fine for the sky map).
320+
skyPoints: sampleSkyPointsByClass(skyByClass),
321+
};
322+
}
168323
}

src/datasets.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* Entry shape:
55
* { id, label, source: "hf", dataset, config?, split? }
66
* dataset is "username/repo-name" on the HF Hub
7+
* split may be a name ("train"), an array of names, or "*" for every split
8+
* in the config (merged into one contiguous dataset).
79
*
810
* Users can also load arbitrary HF datasets by typing user/name in the
911
* welcome modal, or load a local Arrow shard directory when self-hosting.
@@ -15,6 +17,6 @@ export const DATASETS = [
1517
source: "hf",
1618
dataset: "StarEmbed/ZTF_40k",
1719
config: "default",
18-
split: "train",
20+
split: "*",
1921
},
2022
];

0 commit comments

Comments
 (0)