@hviana/sema 0.5.9 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +20 -4
- package/DATASETS.md +12 -11
- package/dist/example/train_base/cache.d.ts +35 -0
- package/dist/example/train_base/cache.js +211 -0
- package/dist/example/train_base/config.d.ts +21 -0
- package/dist/example/train_base/config.js +94 -0
- package/dist/example/train_base/corpora/aya.d.ts +19 -0
- package/dist/example/train_base/corpora/aya.js +76 -0
- package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
- package/dist/example/train_base/corpora/converted-parquet.js +44 -0
- package/dist/example/train_base/corpora/genknow.d.ts +14 -0
- package/dist/example/train_base/corpora/genknow.js +83 -0
- package/dist/example/train_base/corpora/index.d.ts +29 -0
- package/dist/example/train_base/corpora/index.js +81 -0
- package/dist/example/train_base/corpora/massive.d.ts +7 -0
- package/dist/example/train_base/corpora/massive.js +98 -0
- package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
- package/dist/example/train_base/corpora/oasst2.js +120 -0
- package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
- package/dist/example/train_base/corpora/smolsent.js +156 -0
- package/dist/example/train_base/corpora/soda.d.ts +12 -0
- package/dist/example/train_base/corpora/soda.js +113 -0
- package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
- package/dist/example/train_base/corpora/taskmaster.js +144 -0
- package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
- package/dist/example/train_base/corpora/wiki2.js +132 -0
- package/dist/example/train_base/corpus.d.ts +88 -0
- package/dist/example/train_base/corpus.js +65 -0
- package/dist/example/train_base/discovery.d.ts +48 -0
- package/dist/example/train_base/discovery.js +143 -0
- package/dist/example/train_base/http.d.ts +82 -0
- package/dist/example/train_base/http.js +219 -0
- package/dist/example/train_base/items.d.ts +46 -0
- package/dist/example/train_base/items.js +98 -0
- package/dist/example/train_base/main.d.ts +4 -0
- package/dist/example/train_base/main.js +207 -0
- package/dist/example/train_base/progress.d.ts +34 -0
- package/dist/example/train_base/progress.js +114 -0
- package/dist/example/train_base/readers.d.ts +125 -0
- package/dist/example/train_base/readers.js +391 -0
- package/dist/example/train_base/runtime.d.ts +115 -0
- package/dist/example/train_base/runtime.js +637 -0
- package/dist/example/train_base/stage.d.ts +3 -0
- package/dist/example/train_base/stage.js +246 -0
- package/dist/example/train_base/ui.d.ts +88 -0
- package/dist/example/train_base/ui.js +272 -0
- package/dist/src/mind/mind.d.ts +1 -1
- package/dist/src/mind/mind.js +1 -1
- package/example/train_base/cache.ts +251 -0
- package/example/train_base/config.ts +128 -0
- package/example/train_base/corpora/aya.ts +106 -0
- package/example/train_base/corpora/converted-parquet.ts +64 -0
- package/example/train_base/corpora/genknow.ts +114 -0
- package/example/train_base/corpora/index.ts +88 -0
- package/example/train_base/corpora/massive.ts +111 -0
- package/example/train_base/corpora/oasst2.ts +163 -0
- package/example/train_base/corpora/smolsent.ts +203 -0
- package/example/train_base/corpora/soda.ts +130 -0
- package/example/train_base/corpora/taskmaster.ts +217 -0
- package/example/train_base/corpora/wiki2.ts +190 -0
- package/example/train_base/corpus.ts +150 -0
- package/example/train_base/discovery.ts +203 -0
- package/example/train_base/http.ts +284 -0
- package/example/train_base/items.ts +118 -0
- package/example/train_base/main.ts +240 -0
- package/example/train_base/progress.ts +149 -0
- package/example/train_base/readers.ts +505 -0
- package/example/train_base/runtime.ts +894 -0
- package/example/train_base/stage.ts +276 -0
- package/example/train_base/ui.ts +333 -0
- package/jsr.json +1 -1
- package/package.json +2 -4
- package/src/mind/mind.ts +1 -1
- package/test/13-conversation.test.mjs +1 -1
- package/test/84-composed-answer-honesty.test.mjs +2 -1
- package/test/88-dependency-footprint.test.mjs +99 -0
- package/dist/example/train_base.d.ts +0 -163
- package/dist/example/train_base.js +0 -3220
- package/example/train_base.ts +0 -3882
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// train_base/readers.ts — bytes on disk → rows → deposits.
|
|
2
|
+
//
|
|
3
|
+
// A READER knows a container format (newline-delimited JSON, a JSON array, a
|
|
4
|
+
// Parquet file) and nothing about any corpus. It is handed a row ADAPTER and
|
|
5
|
+
// deposits whatever that adapter returns. The pairing is free: any corpus can
|
|
6
|
+
// use any reader, which is the whole point of separating them.
|
|
7
|
+
//
|
|
8
|
+
// Every reader stops at an ITEM boundary — never mid-deposit — on any of three
|
|
9
|
+
// signals: the run's abort signal, `shouldStop` (a stage budget), or `onExample`
|
|
10
|
+
// returning false (the MAX_MB cap). A stopped read leaves its file un-completed
|
|
11
|
+
// so a resume re-reads it from the top, which is safe because deposition is
|
|
12
|
+
// idempotent.
|
|
13
|
+
//
|
|
14
|
+
// THE ONLY THIRD-PARTY CODE IN THIS REPOSITORY IS BELOW, and it is LAZILY
|
|
15
|
+
// LOADED. Sema itself imports nothing outside `node:` — that is a product
|
|
16
|
+
// property, not an accident (AGENTS.md §6) — and this trainer is an EXAMPLE,
|
|
17
|
+
// not part of the library. hyparquet (+ its Snappy codec) is therefore a dev
|
|
18
|
+
// dependency, and it is loaded by a dynamic import the first time a Parquet
|
|
19
|
+
// corpus is actually read: a curriculum with no Parquet stage (SmolSent,
|
|
20
|
+
// oasst2, Taskmaster, General-Knowledge) runs with the package absent
|
|
21
|
+
// entirely, and one that needs it fails with a sentence instead of a module
|
|
22
|
+
// resolution error.
|
|
23
|
+
|
|
24
|
+
import type { CachedIngest } from "../../src/index.js";
|
|
25
|
+
import { isEpisode, itemBytes, type TrainingItem } from "./items.js";
|
|
26
|
+
import { PARQUET_BATCH_BYTES } from "./config.js";
|
|
27
|
+
import { openAsBlob } from "node:fs";
|
|
28
|
+
|
|
29
|
+
/** Turn ONE raw row into deposits, or null/[] when the row carries nothing
|
|
30
|
+
* usable. Pure: no I/O, no counters, no logging. */
|
|
31
|
+
export type RowAdapter = (row: unknown) => TrainingItem[] | null;
|
|
32
|
+
|
|
33
|
+
/** What a read produced. `skipped` and `unusable` are deliberately SEPARATE:
|
|
34
|
+
* a line that failed to parse is a defect in the file, while a row the adapter
|
|
35
|
+
* declined is a normal, expected outcome for a corpus being filtered (oasst2
|
|
36
|
+
* drops every single-turn tree by design). Collapsing them — as the two
|
|
37
|
+
* original line readers each did, in opposite directions — makes one of the
|
|
38
|
+
* two log lines a lie. */
|
|
39
|
+
export interface FileResult {
|
|
40
|
+
examples: number; // deposits made
|
|
41
|
+
rowsUsed: number; // rows that produced at least one deposit
|
|
42
|
+
skipped: number; // malformed / oversize records — a defect in the file
|
|
43
|
+
unusable: number; // rows the adapter declined — normal filtering
|
|
44
|
+
stopped: boolean; // stopped early by a cap, a budget, or a signal
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Everything a reader needs from the run: where to deposit, what to count,
|
|
48
|
+
* and how to be stopped. */
|
|
49
|
+
export interface ReadContext {
|
|
50
|
+
ci: CachedIngest;
|
|
51
|
+
/** Called once per deposit with its UTF-8 content size. Returns false to
|
|
52
|
+
* stop the read (the MAX_MB cap, or a pending shutdown). */
|
|
53
|
+
onExample: (contentBytes: number) => Promise<boolean>;
|
|
54
|
+
/** Feeds the reservoir behind the checkpoint recall box. */
|
|
55
|
+
sample: (it: TrainingItem) => void;
|
|
56
|
+
signal: AbortSignal;
|
|
57
|
+
/** A stage-level budget. Checked per row and before each Parquet batch is
|
|
58
|
+
* decoded — a budget must STOP the read rather than reject rows: left to
|
|
59
|
+
* reject, a budgeted stage still DECODES every remaining row-group (143,346
|
|
60
|
+
* rows of one 86.7 MB SODA shard) and reports them as "unusable" when
|
|
61
|
+
* nothing was wrong with them, which is a lie in the run log.
|
|
62
|
+
*
|
|
63
|
+
* Measured honestly: on that shard the wall time did NOT improve (2m 35s ->
|
|
64
|
+
* 2m 37s), because a budgeted run is dominated by depositing the rows it DID
|
|
65
|
+
* take, not by scanning past the ones it did not. The win here is a truthful
|
|
66
|
+
* log and the CPU/allocation of ~143k skipped row decodes, not elapsed time.
|
|
67
|
+
* A larger shard past a small budget is where the decode cost would show. */
|
|
68
|
+
shouldStop?: () => boolean;
|
|
69
|
+
|
|
70
|
+
/** Rows to SKIP before depositing anything — the position a previous run
|
|
71
|
+
* reached, taken from the durable cursor (see runtime.ts). Resume used to
|
|
72
|
+
* mean "re-read this unit from the top", which was safe but re-deposited
|
|
73
|
+
* everything already stored and counted it a second time; the store then
|
|
74
|
+
* reported up to 77% more examples than it held.
|
|
75
|
+
*
|
|
76
|
+
* Skipping is only sound because the cursor is written in the SAME COMMIT
|
|
77
|
+
* that flushes the deposits it counts, so a row before the cursor is
|
|
78
|
+
* necessarily durable. A skipped row is neither parsed nor counted, so a
|
|
79
|
+
* resumed read's log line describes what THIS read did and nothing else. */
|
|
80
|
+
startRow?: number;
|
|
81
|
+
|
|
82
|
+
/** "Row `rows` is FULLY dealt with" — every item it produced is deposited, or
|
|
83
|
+
* it produced none. Called at ROW BOUNDARIES ONLY, and never for a row the
|
|
84
|
+
* read stopped in the middle of.
|
|
85
|
+
*
|
|
86
|
+
* That boundary is the whole point. A checkpoint fires per DEPOSIT, and a row
|
|
87
|
+
* can produce many (2Wiki emits ~5 facts per row, a dialogue one per turn),
|
|
88
|
+
* so a position recorded when a row STARTS would mark it consumed while some
|
|
89
|
+
* of its items were still unwritten — and the resume would skip them. Data
|
|
90
|
+
* loss, silently. Advancing only here means the worst case is re-depositing
|
|
91
|
+
* one row, which is idempotent and counted once. */
|
|
92
|
+
onRowDone?: (rows: number) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A reader: read `filePath`, deposit every row `toItems` accepts. */
|
|
96
|
+
export type Reader = (
|
|
97
|
+
filePath: string,
|
|
98
|
+
toItems: RowAdapter,
|
|
99
|
+
rc: ReadContext,
|
|
100
|
+
) => Promise<FileResult>;
|
|
101
|
+
|
|
102
|
+
/** Deposit a row's items: an experience via ingest(text), an episode via
|
|
103
|
+
* ingest(context, continuation). After each, the per-example callback receives
|
|
104
|
+
* the item's UTF-8 content size — the quantity the scaling suite
|
|
105
|
+
* (14-scaling.test.mjs) reports as a constant KB/s — then gates the global
|
|
106
|
+
* example count and checkpointing (returns false to stop). */
|
|
107
|
+
export async function ingestItems(
|
|
108
|
+
ci: CachedIngest,
|
|
109
|
+
items: TrainingItem[],
|
|
110
|
+
onItem: (contentBytes: number) => Promise<boolean>,
|
|
111
|
+
sample?: (it: TrainingItem) => void,
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
for (const it of items) {
|
|
114
|
+
if (isEpisode(it)) await ci.ingest(it.context, it.continuation);
|
|
115
|
+
else await ci.ingest(it);
|
|
116
|
+
sample?.(it);
|
|
117
|
+
if (!(await onItem(itemBytes(it)))) return false; // stop requested
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Shared tail of every reader: deposit one row's items and keep the counts. */
|
|
123
|
+
async function depositRow(
|
|
124
|
+
row: unknown,
|
|
125
|
+
toItems: RowAdapter,
|
|
126
|
+
rc: ReadContext,
|
|
127
|
+
res: FileResult,
|
|
128
|
+
): Promise<boolean> {
|
|
129
|
+
const items = toItems(row);
|
|
130
|
+
if (!items || items.length === 0) {
|
|
131
|
+
res.unusable++;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
res.rowsUsed++;
|
|
135
|
+
return ingestItems(rc.ci, items, async (contentBytes) => {
|
|
136
|
+
res.examples++;
|
|
137
|
+
return rc.onExample(contentBytes);
|
|
138
|
+
}, rc.sample);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const blank = (): FileResult => ({
|
|
142
|
+
examples: 0,
|
|
143
|
+
rowsUsed: 0,
|
|
144
|
+
skipped: 0,
|
|
145
|
+
unusable: 0,
|
|
146
|
+
stopped: false,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/** Newline-delimited JSON, optionally gzipped.
|
|
150
|
+
*
|
|
151
|
+
* ONE reader serves both the plain JSONL sources and the gzipped oasst2 tree
|
|
152
|
+
* dump: the only difference between them is a `DecompressionStream("gzip")` in
|
|
153
|
+
* the pipeline, and duplicating an 80-line splitter to express that was how
|
|
154
|
+
* the two copies drifted apart in the first place.
|
|
155
|
+
*
|
|
156
|
+
* Lines are split without buffering the whole file OR an unbounded line: a
|
|
157
|
+
* record longer than `maxLineChars` is dropped (counted `skipped`) and the
|
|
158
|
+
* stream continues at the next newline, so a corrupt record can never exhaust
|
|
159
|
+
* memory or abort a good file. */
|
|
160
|
+
export const lines = (
|
|
161
|
+
opts: { gzip?: boolean; maxLineChars: number },
|
|
162
|
+
): Reader =>
|
|
163
|
+
async (filePath, toItems, rc) => {
|
|
164
|
+
const res = blank();
|
|
165
|
+
const blob = await openAsBlob(filePath);
|
|
166
|
+
// gzip is a web standard here too (DecompressionStream), so the compressed
|
|
167
|
+
// and plain forms differ by exactly one pipe stage and nothing else.
|
|
168
|
+
const reader = (opts.gzip
|
|
169
|
+
? blob.stream()
|
|
170
|
+
.pipeThrough(new DecompressionStream("gzip"))
|
|
171
|
+
.pipeThrough(new TextDecoderStream())
|
|
172
|
+
: blob.stream()
|
|
173
|
+
.pipeThrough(new TextDecoderStream())).getReader();
|
|
174
|
+
let leftover = "", dropping = false;
|
|
175
|
+
|
|
176
|
+
// A "row" here is a non-blank line, counted whether or not it parses — so the
|
|
177
|
+
// position is a property of the FILE, reproducible on a later run.
|
|
178
|
+
const skip = rc.startRow ?? 0;
|
|
179
|
+
let rowIndex = 0;
|
|
180
|
+
|
|
181
|
+
const processLine = async (line: string): Promise<boolean> => {
|
|
182
|
+
if (!line.trim()) return true;
|
|
183
|
+
rowIndex++;
|
|
184
|
+
// Already deposited by an earlier run: advance the position, touch nothing
|
|
185
|
+
// else. Not parsed and not counted, so this read's numbers describe only
|
|
186
|
+
// the rows it actually trained.
|
|
187
|
+
if (rowIndex <= skip) {
|
|
188
|
+
rc.onRowDone?.(rowIndex);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (rc.shouldStop?.()) return false;
|
|
192
|
+
let row: unknown;
|
|
193
|
+
try {
|
|
194
|
+
row = JSON.parse(line);
|
|
195
|
+
} catch {
|
|
196
|
+
res.skipped++;
|
|
197
|
+
rc.onRowDone?.(rowIndex); // nothing to deposit — the row is dealt with
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const ok = await depositRow(row, toItems, rc, res);
|
|
201
|
+
if (ok) rc.onRowDone?.(rowIndex); // every item landed
|
|
202
|
+
return ok;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
while (true) {
|
|
207
|
+
const { done, value } = await reader.read();
|
|
208
|
+
if (done) break;
|
|
209
|
+
let chunk = value;
|
|
210
|
+
for (;;) {
|
|
211
|
+
const nl = chunk.indexOf("\n");
|
|
212
|
+
if (nl < 0) {
|
|
213
|
+
if (!dropping) {
|
|
214
|
+
if (leftover.length + chunk.length > opts.maxLineChars) {
|
|
215
|
+
leftover = "";
|
|
216
|
+
dropping = true;
|
|
217
|
+
res.skipped++;
|
|
218
|
+
} else leftover += chunk;
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
const part = chunk.slice(0, nl);
|
|
223
|
+
chunk = chunk.slice(nl + 1);
|
|
224
|
+
if (dropping) {
|
|
225
|
+
dropping = false;
|
|
226
|
+
leftover = "";
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (leftover.length + part.length > opts.maxLineChars) {
|
|
230
|
+
leftover = "";
|
|
231
|
+
res.skipped++;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const line = leftover + part;
|
|
235
|
+
leftover = "";
|
|
236
|
+
if (!(await processLine(line))) {
|
|
237
|
+
res.stopped = true;
|
|
238
|
+
return res;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!dropping && leftover.trim()) {
|
|
243
|
+
if (!(await processLine(leftover))) res.stopped = true;
|
|
244
|
+
}
|
|
245
|
+
return res;
|
|
246
|
+
} finally {
|
|
247
|
+
// CANCEL, not releaseLock: a read that returns early (a budget, the MAX_MB
|
|
248
|
+
// cap, a signal) leaves the file source open otherwise, to be closed
|
|
249
|
+
// whenever the collector gets to it. Measured, that is tidiness rather than
|
|
250
|
+
// a leak — 300 abandoned reads peaked at 42 open descriptors against 38
|
|
251
|
+
// with cancel — but "closed when we are done with it" is the cheaper thing
|
|
252
|
+
// to reason about, and cancel releases the lock too.
|
|
253
|
+
try {
|
|
254
|
+
await reader.cancel();
|
|
255
|
+
} catch { /* already closed */ }
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** A whole-file JSON ARRAY of rows. The arrays this reads are small enough
|
|
260
|
+
* (~16 MB) to parse whole; a huge file would be rejected by the cache ceiling
|
|
261
|
+
* long before this. */
|
|
262
|
+
export const jsonArray = (): Reader => async (filePath, toItems, rc) => {
|
|
263
|
+
const res = blank();
|
|
264
|
+
const blob = await openAsBlob(filePath);
|
|
265
|
+
let arr: unknown;
|
|
266
|
+
try {
|
|
267
|
+
arr = JSON.parse(await blob.text());
|
|
268
|
+
} catch (e) {
|
|
269
|
+
throw new Error(`invalid JSON: ${(e as Error).message}`);
|
|
270
|
+
}
|
|
271
|
+
// A file that parses but is not an array is a DIFFERENT CONTAINER, not an
|
|
272
|
+
// empty one, so it is an error rather than zero rows. Silently reading it as
|
|
273
|
+
// empty marked the unit complete, logged "0 facts", and never looked again —
|
|
274
|
+
// and the live path to that is a LOCAL_PATH run, where the Taskmaster stage
|
|
275
|
+
// takes every *.json in the directory while the remote listing filters out
|
|
276
|
+
// exactly the two files (ontology.json, sample.json) that are not arrays of
|
|
277
|
+
// conversations. Throwing leaves the unit un-completed and says which file.
|
|
278
|
+
if (!Array.isArray(arr)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`expected a JSON array of rows, got ${
|
|
281
|
+
arr === null ? "null" : Array.isArray(arr) ? "array" : typeof arr
|
|
282
|
+
}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const rows: unknown[] = arr;
|
|
286
|
+
const skip = rc.startRow ?? 0;
|
|
287
|
+
for (let i = 0; i < rows.length; i++) {
|
|
288
|
+
if (i < skip) { // deposited by an earlier run — see ReadContext
|
|
289
|
+
rc.onRowDone?.(i + 1);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (rc.signal.aborted || rc.shouldStop?.()) {
|
|
293
|
+
res.stopped = true;
|
|
294
|
+
return res;
|
|
295
|
+
}
|
|
296
|
+
if (!(await depositRow(rows[i], toItems, rc, res))) {
|
|
297
|
+
res.stopped = true;
|
|
298
|
+
return res;
|
|
299
|
+
}
|
|
300
|
+
rc.onRowDone?.(i + 1);
|
|
301
|
+
}
|
|
302
|
+
return res;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/** How many rows to materialise in one read from a row-group of `rgRows` rows
|
|
306
|
+
* occupying `groupBytes` uncompressed bytes, under a `budgetBytes` target.
|
|
307
|
+
*
|
|
308
|
+
* The group's own footer statistics give the mean row width, so the batch
|
|
309
|
+
* follows the CORPUS's row size rather than the writer's layout: wide rows
|
|
310
|
+
* (SODA carries a whole dialogue per row) batch smaller than narrow ones at
|
|
311
|
+
* the same memory cost. Never exceeds the group — a batch is a subdivision of
|
|
312
|
+
* a group, never a span across two, because `parquetReadObjects` is given an
|
|
313
|
+
* absolute row range and column chunks are per-group. Never returns 0, or the
|
|
314
|
+
* read loop could not advance.
|
|
315
|
+
*
|
|
316
|
+
* A writer that omits `total_byte_size` yields `groupBytes <= 0`; the batch is
|
|
317
|
+
* then the whole group, which is exactly the behaviour this replaced. That
|
|
318
|
+
* fallback is safe for every file we read today (all three report it) and
|
|
319
|
+
* degrades to the old memory profile rather than to a wrong result. */
|
|
320
|
+
export function parquetBatchRows(
|
|
321
|
+
rgRows: number,
|
|
322
|
+
groupBytes: number,
|
|
323
|
+
budgetBytes: number,
|
|
324
|
+
): number {
|
|
325
|
+
if (!(rgRows > 0)) return 0; // empty group — the caller skips it
|
|
326
|
+
if (!(groupBytes > 0) || !Number.isFinite(groupBytes)) return rgRows;
|
|
327
|
+
const perRow = groupBytes / rgRows;
|
|
328
|
+
const fit = Math.floor(budgetBytes / perRow);
|
|
329
|
+
return Math.min(rgRows, Math.max(1, fit));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** hyparquet + its Snappy codec, resolved on FIRST USE and remembered.
|
|
333
|
+
*
|
|
334
|
+
* A pure-JS, dependency-free Parquet reader driven over a web-standard Blob
|
|
335
|
+
* byte source. It is the one thing the web platform cannot do alone, and the
|
|
336
|
+
* one package this repository pulls in — so it is loaded here, where it is
|
|
337
|
+
* used, rather than at module scope, where it would become a load-time
|
|
338
|
+
* requirement of the whole trainer. */
|
|
339
|
+
let parquetLib:
|
|
340
|
+
| Promise<{
|
|
341
|
+
metadata: typeof import("hyparquet").parquetMetadataAsync;
|
|
342
|
+
readObjects: typeof import("hyparquet").parquetReadObjects;
|
|
343
|
+
compressors: typeof import("hyparquet-compressors").compressors;
|
|
344
|
+
}>
|
|
345
|
+
| null = null;
|
|
346
|
+
|
|
347
|
+
const loadParquet = () => (parquetLib ??= (async () => {
|
|
348
|
+
try {
|
|
349
|
+
const [hp, hc] = await Promise.all([
|
|
350
|
+
import("hyparquet"),
|
|
351
|
+
import("hyparquet-compressors"),
|
|
352
|
+
]);
|
|
353
|
+
return {
|
|
354
|
+
metadata: hp.parquetMetadataAsync,
|
|
355
|
+
readObjects: hp.parquetReadObjects,
|
|
356
|
+
compressors: hc.compressors,
|
|
357
|
+
};
|
|
358
|
+
} catch (e) {
|
|
359
|
+
// A missing package here is a SETUP problem with a specific remedy, so say
|
|
360
|
+
// the remedy. Anything else (a genuine load error inside the package) is
|
|
361
|
+
// rethrown untouched.
|
|
362
|
+
const code = (e as { code?: string })?.code;
|
|
363
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
|
|
364
|
+
throw new Error(
|
|
365
|
+
"this corpus ships as Parquet and needs the hyparquet reader, which " +
|
|
366
|
+
"is a DEV dependency of this example (the library itself has none). " +
|
|
367
|
+
"Run `npm install` in the repository, or disable the Parquet " +
|
|
368
|
+
"stages: AYA=0 WIKI2=0 SODA=0 MASSIVE=0.",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
throw e;
|
|
372
|
+
}
|
|
373
|
+
})());
|
|
374
|
+
|
|
375
|
+
/** The top-level column names a Parquet file actually carries, taken from the
|
|
376
|
+
* chunk metadata (`path_in_schema[0]` is the top-level name, which is exactly
|
|
377
|
+
* what hyparquet matches a `columns` request against). */
|
|
378
|
+
const columnsOf = (
|
|
379
|
+
meta: {
|
|
380
|
+
row_groups: Array<
|
|
381
|
+
{ columns: Array<{ meta_data?: { path_in_schema: string[] } }> }
|
|
382
|
+
>;
|
|
383
|
+
},
|
|
384
|
+
): Set<string> => {
|
|
385
|
+
const out = new Set<string>();
|
|
386
|
+
for (const rg of meta.row_groups) {
|
|
387
|
+
for (const c of rg.columns) {
|
|
388
|
+
const top = c.meta_data?.path_in_schema?.[0];
|
|
389
|
+
if (top) out.add(top);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
/** Reject a column projection that names a column the file does not have.
|
|
396
|
+
*
|
|
397
|
+
* hyparquet ignores an unknown name in `columns` rather than complaining, so a
|
|
398
|
+
* typo would read NO columns, hand every adapter an empty row, and finish with
|
|
399
|
+
* "0 facts" and no error at all. A projection is a claim about the file, so a
|
|
400
|
+
* wrong claim is worth stopping for — and the message names both what is
|
|
401
|
+
* missing and what is there, which is what you need to fix it. */
|
|
402
|
+
function checkColumns(available: Set<string>, want: string[]): string[] {
|
|
403
|
+
const missing = want.filter((c) => !available.has(c));
|
|
404
|
+
if (missing.length > 0) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`column(s) not in this file: ${missing.join(", ")} — ` +
|
|
407
|
+
`it carries ${[...available].join(", ")}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return want;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Parquet, read in bounded row batches with hyparquet (+Snappy from
|
|
414
|
+
* hyparquet-compressors) over a web-standard Blob byte source. At most
|
|
415
|
+
* `batchBytes` of source rows are materialised at a time, so neither a
|
|
416
|
+
* multi-hundred-MB file nor a file written as ONE giant row-group loads whole
|
|
417
|
+
* into memory.
|
|
418
|
+
*
|
|
419
|
+
* Batching also makes a single-group file INTERRUPTIBLE: the abort check runs
|
|
420
|
+
* per batch, where before a 1.19M-row group could not be cancelled at all.
|
|
421
|
+
*
|
|
422
|
+
* `columns` PROJECTS the read down to the columns the adapter actually uses.
|
|
423
|
+
* That is not only a memory economy: 2Wiki's `context` column holds the
|
|
424
|
+
* Wikipedia prose the adapter exists to avoid depositing, and naming the
|
|
425
|
+
* columns makes that exclusion structural — the bytes are never decoded at
|
|
426
|
+
* all — in the same way reading only `utterances[].text` structurally excludes
|
|
427
|
+
* Taskmaster's `instructions` scaffolding. Absent ⇒ every column, as before. */
|
|
428
|
+
export const parquet = (
|
|
429
|
+
opts: { batchBytes?: number; columns?: string[] } = {},
|
|
430
|
+
): Reader =>
|
|
431
|
+
async (filePath, toItems, rc) => {
|
|
432
|
+
const { metadata, readObjects, compressors } = await loadParquet();
|
|
433
|
+
const budget = opts.batchBytes ?? PARQUET_BATCH_BYTES;
|
|
434
|
+
const res = blank();
|
|
435
|
+
const blob = await openAsBlob(filePath);
|
|
436
|
+
const file = {
|
|
437
|
+
byteLength: blob.size,
|
|
438
|
+
slice: async (start: number, end?: number) =>
|
|
439
|
+
await blob.slice(start, end ?? blob.size).arrayBuffer(),
|
|
440
|
+
};
|
|
441
|
+
const meta = await metadata(file);
|
|
442
|
+
const columns = opts.columns
|
|
443
|
+
? checkColumns(columnsOf(meta), opts.columns)
|
|
444
|
+
: undefined;
|
|
445
|
+
// A "row" here is the file's ABSOLUTE row index, which is what hyparquet's
|
|
446
|
+
// rowStart/rowEnd already speak in — so resuming is not merely cheaper than
|
|
447
|
+
// re-reading, it decodes nothing at all before the cursor.
|
|
448
|
+
const skip = rc.startRow ?? 0;
|
|
449
|
+
let rowStart = 0;
|
|
450
|
+
for (const rg of meta.row_groups) {
|
|
451
|
+
const rgRows = Number(rg.num_rows);
|
|
452
|
+
const rgEnd = rowStart + rgRows;
|
|
453
|
+
if (rgEnd <= skip) {
|
|
454
|
+
// Entirely behind the cursor: never fetched, never decompressed.
|
|
455
|
+
rowStart = rgEnd;
|
|
456
|
+
rc.onRowDone?.(rowStart);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
// `total_byte_size` covers EVERY column, including ones a projection skips,
|
|
460
|
+
// so a projected read materialises less than the budget rather than more.
|
|
461
|
+
// Erring small is the safe direction for a memory budget, and correcting it
|
|
462
|
+
// per-column would tie the batch size to a layout detail for no gain.
|
|
463
|
+
const batchRows = parquetBatchRows(
|
|
464
|
+
rgRows,
|
|
465
|
+
Number(rg.total_byte_size ?? 0),
|
|
466
|
+
budget,
|
|
467
|
+
);
|
|
468
|
+
if (batchRows <= 0) continue; // empty group
|
|
469
|
+
// Resume inside a group: begin at the cursor, not at the group's first row.
|
|
470
|
+
if (rowStart < skip) rowStart = skip;
|
|
471
|
+
// Materialise one bounded batch at a time, then deposit its rows.
|
|
472
|
+
while (rowStart < rgEnd) {
|
|
473
|
+
if (rc.signal.aborted || rc.shouldStop?.()) {
|
|
474
|
+
res.stopped = true;
|
|
475
|
+
return res;
|
|
476
|
+
}
|
|
477
|
+
const batchStart = rowStart;
|
|
478
|
+
const rowEnd = Math.min(rowStart + batchRows, rgEnd);
|
|
479
|
+
const rows = await readObjects({
|
|
480
|
+
file,
|
|
481
|
+
// Hand back the footer we already parsed: without it every batch
|
|
482
|
+
// re-reads and re-parses the file's metadata, which on a large shard
|
|
483
|
+
// means dozens of redundant footer parses per file.
|
|
484
|
+
metadata: meta,
|
|
485
|
+
compressors,
|
|
486
|
+
columns,
|
|
487
|
+
rowStart,
|
|
488
|
+
rowEnd,
|
|
489
|
+
});
|
|
490
|
+
rowStart = rowEnd;
|
|
491
|
+
for (let i = 0; i < rows.length; i++) {
|
|
492
|
+
if (rc.shouldStop?.()) {
|
|
493
|
+
res.stopped = true;
|
|
494
|
+
return res;
|
|
495
|
+
}
|
|
496
|
+
if (!(await depositRow(rows[i], toItems, rc, res))) {
|
|
497
|
+
res.stopped = true;
|
|
498
|
+
return res;
|
|
499
|
+
}
|
|
500
|
+
rc.onRowDone?.(batchStart + i + 1);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return res;
|
|
505
|
+
};
|