@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.
Files changed (79) hide show
  1. package/AGENTS.md +20 -4
  2. package/DATASETS.md +12 -11
  3. package/dist/example/train_base/cache.d.ts +35 -0
  4. package/dist/example/train_base/cache.js +211 -0
  5. package/dist/example/train_base/config.d.ts +21 -0
  6. package/dist/example/train_base/config.js +94 -0
  7. package/dist/example/train_base/corpora/aya.d.ts +19 -0
  8. package/dist/example/train_base/corpora/aya.js +76 -0
  9. package/dist/example/train_base/corpora/converted-parquet.d.ts +14 -0
  10. package/dist/example/train_base/corpora/converted-parquet.js +44 -0
  11. package/dist/example/train_base/corpora/genknow.d.ts +14 -0
  12. package/dist/example/train_base/corpora/genknow.js +83 -0
  13. package/dist/example/train_base/corpora/index.d.ts +29 -0
  14. package/dist/example/train_base/corpora/index.js +81 -0
  15. package/dist/example/train_base/corpora/massive.d.ts +7 -0
  16. package/dist/example/train_base/corpora/massive.js +98 -0
  17. package/dist/example/train_base/corpora/oasst2.d.ts +52 -0
  18. package/dist/example/train_base/corpora/oasst2.js +120 -0
  19. package/dist/example/train_base/corpora/smolsent.d.ts +23 -0
  20. package/dist/example/train_base/corpora/smolsent.js +156 -0
  21. package/dist/example/train_base/corpora/soda.d.ts +12 -0
  22. package/dist/example/train_base/corpora/soda.js +113 -0
  23. package/dist/example/train_base/corpora/taskmaster.d.ts +15 -0
  24. package/dist/example/train_base/corpora/taskmaster.js +144 -0
  25. package/dist/example/train_base/corpora/wiki2.d.ts +23 -0
  26. package/dist/example/train_base/corpora/wiki2.js +132 -0
  27. package/dist/example/train_base/corpus.d.ts +88 -0
  28. package/dist/example/train_base/corpus.js +65 -0
  29. package/dist/example/train_base/discovery.d.ts +48 -0
  30. package/dist/example/train_base/discovery.js +143 -0
  31. package/dist/example/train_base/http.d.ts +82 -0
  32. package/dist/example/train_base/http.js +219 -0
  33. package/dist/example/train_base/items.d.ts +46 -0
  34. package/dist/example/train_base/items.js +98 -0
  35. package/dist/example/train_base/main.d.ts +4 -0
  36. package/dist/example/train_base/main.js +207 -0
  37. package/dist/example/train_base/progress.d.ts +34 -0
  38. package/dist/example/train_base/progress.js +114 -0
  39. package/dist/example/train_base/readers.d.ts +125 -0
  40. package/dist/example/train_base/readers.js +391 -0
  41. package/dist/example/train_base/runtime.d.ts +115 -0
  42. package/dist/example/train_base/runtime.js +637 -0
  43. package/dist/example/train_base/stage.d.ts +3 -0
  44. package/dist/example/train_base/stage.js +246 -0
  45. package/dist/example/train_base/ui.d.ts +88 -0
  46. package/dist/example/train_base/ui.js +272 -0
  47. package/dist/src/mind/mind.d.ts +1 -1
  48. package/dist/src/mind/mind.js +1 -1
  49. package/example/train_base/cache.ts +251 -0
  50. package/example/train_base/config.ts +128 -0
  51. package/example/train_base/corpora/aya.ts +106 -0
  52. package/example/train_base/corpora/converted-parquet.ts +64 -0
  53. package/example/train_base/corpora/genknow.ts +114 -0
  54. package/example/train_base/corpora/index.ts +88 -0
  55. package/example/train_base/corpora/massive.ts +111 -0
  56. package/example/train_base/corpora/oasst2.ts +163 -0
  57. package/example/train_base/corpora/smolsent.ts +203 -0
  58. package/example/train_base/corpora/soda.ts +130 -0
  59. package/example/train_base/corpora/taskmaster.ts +217 -0
  60. package/example/train_base/corpora/wiki2.ts +190 -0
  61. package/example/train_base/corpus.ts +150 -0
  62. package/example/train_base/discovery.ts +203 -0
  63. package/example/train_base/http.ts +284 -0
  64. package/example/train_base/items.ts +118 -0
  65. package/example/train_base/main.ts +240 -0
  66. package/example/train_base/progress.ts +149 -0
  67. package/example/train_base/readers.ts +505 -0
  68. package/example/train_base/runtime.ts +894 -0
  69. package/example/train_base/stage.ts +276 -0
  70. package/example/train_base/ui.ts +333 -0
  71. package/jsr.json +1 -1
  72. package/package.json +2 -4
  73. package/src/mind/mind.ts +1 -1
  74. package/test/13-conversation.test.mjs +1 -1
  75. package/test/84-composed-answer-honesty.test.mjs +2 -1
  76. package/test/88-dependency-footprint.test.mjs +99 -0
  77. package/dist/example/train_base.d.ts +0 -163
  78. package/dist/example/train_base.js +0 -3220
  79. 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
+ };