keepset 0.38.0 → 0.39.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.
@@ -1,4 +1,4 @@
1
- import { $ as ArchiveError, H as getCollectionTarget, L as getOwn, U as getTarget, at as RecipeError, it as NativeParseError, rt as KeepsetError, t as getDataset, z as countTarget } from "../bundled-U2l4WT5T.js";
1
+ import { $ as ArchiveError, H as getCollectionTarget, L as getOwn, U as getTarget, at as RecipeError, it as NativeParseError, rt as KeepsetError, t as getDataset, z as countTarget } from "../bundled-6dYCPVD-.js";
2
2
  import { RoundTripMismatchError, assertRoundTrip, encode } from "crossmap";
3
3
  import { z } from "zod";
4
4
  import { createHash } from "node:crypto";
@@ -127,6 +127,15 @@ function endpoint$1(fields, column, lineNumber) {
127
127
  return value.trim();
128
128
  }
129
129
  /**
130
+ * Whether a line meets one of the recipe's `where` conditions.
131
+ * @param fields - The line's fields, at least as many as the highest column the recipe names.
132
+ * @param condition - The condition.
133
+ * @returns True when the condition's column, trimmed, equals its value.
134
+ */
135
+ function meets(fields, condition) {
136
+ return fields[condition.column]?.trim() === condition.equals;
137
+ }
138
+ /**
130
139
  * Resolves `detect` to a fixed delimiter from the first data line: tab when it holds a tab, comma when it holds a comma, whitespace otherwise. The rule is a function of the file's bytes, so a recipe that detects still reads the same way every time.
131
140
  * @param dataLine - The first line that is neither skipped, blank nor a comment.
132
141
  * @returns The fixed delimiter.
@@ -149,31 +158,33 @@ function multiplicity(fields, column, lineNumber) {
149
158
  return Number(value);
150
159
  }
151
160
  /**
152
- * Parses a delimited edge list: one edge per line, source and target in the configured columns, after `skipLines` header lines, with blank lines and lines whose first non-blank characters are a comment prefix skipped. A `detect` delimiter is fixed from the first data line. A recipe that names a `keep` column reads only the lines whose field there is one of its values; one that names a `multiplicityColumn` emits as many edges for a line as the field says, none for a zero; one that says `mirrored` emits each pair of mirror-image lines once. Nodes are the endpoints of the lines read, in order of first appearance.
153
- * @param text - The file text.
161
+ * Parses a delimited edge list: one edge per line, source and target in the configured columns, after `skipLines` header lines, with blank lines and lines whose first non-blank characters are a comment prefix skipped. A `detect` delimiter is fixed from the first data line. A recipe that names a `keep` column reads only the lines whose field there is one of its values, and one that states `where` reads only the lines whose listed columns, trimmed, equal their values, so a file that records several kinds of row (an interaction file listing genetic beside physical interactions) is read for the rows the dataset is made of; a line that is not read is still checked for the fields it needs. A recipe that names a `multiplicityColumn` emits as many edges for a line as the field says, none for a zero; one that says `mirrored` emits each pair of mirror-image lines once. Nodes are the endpoints of the lines read, in order of first appearance. The text is read line by line, so a file longer than the engine's longest string is read too.
162
+ * @param text - The file text, whole or in pieces.
154
163
  * @param options - The recipe's edge-list parser.
155
164
  * @returns The native graph.
156
- * @throws NativeParseError naming the line when a data line has too few fields, an empty endpoint or a multiplicity that is not a whole number, when a mirrored table has a line without its mirror, or when the file holds no edges.
165
+ * @throws NativeParseError naming the line when a data line has too few fields, an empty endpoint or a multiplicity that is not a whole number, when a mirrored table has a line without its mirror, or when no line is an edge.
157
166
  */
158
167
  function parseEdgeList(text, options) {
159
168
  let split = options.delimiter === "detect" ? void 0 : splitters[options.delimiter];
160
- const needed = Math.max(options.sourceColumn, options.targetColumn, options.keep?.column ?? 0, options.multiplicityColumn ?? 0) + 1;
169
+ const { where } = options;
170
+ const needed = Math.max(options.sourceColumn, options.targetColumn, options.keep?.column ?? 0, options.multiplicityColumn ?? 0, ...where === void 0 ? [] : where.map((condition) => condition.column)) + 1;
161
171
  const order = new NodeOrder();
162
172
  const edges = [];
163
173
  const awaiting = /* @__PURE__ */ new Map();
164
- const all = lines(text);
165
- for (const [index, line] of all.entries()) {
166
- const lineNumber = index + 1;
174
+ let lastLine = 0;
175
+ forEachLine(typeof text === "string" ? [text] : text, (line, lineNumber) => {
176
+ lastLine = lineNumber;
167
177
  const content = line.trimStart();
168
- if (index < options.skipLines || content === "" || options.commentPrefixes.some((prefix) => content.startsWith(prefix))) continue;
178
+ if (lineNumber <= options.skipLines || content === "" || options.commentPrefixes.some((prefix) => content.startsWith(prefix))) return;
169
179
  split ??= splitters[detect(line)];
170
180
  const fields = split(line);
171
181
  if (fields.length < needed) throw new NativeParseError("edge-list", lineNumber, `expected at least ${String(needed)} fields, found ${String(fields.length)}`);
172
- if (options.keep !== void 0 && !options.keep.values.includes(fields[options.keep.column]?.trim() ?? "")) continue;
182
+ if (options.keep !== void 0 && !options.keep.values.includes(fields[options.keep.column]?.trim() ?? "")) return;
183
+ if (where !== void 0 && !where.every((condition) => meets(fields, condition))) return;
173
184
  const source = endpoint$1(fields, options.sourceColumn, lineNumber);
174
185
  const target = endpoint$1(fields, options.targetColumn, lineNumber);
175
186
  const count = options.multiplicityColumn === void 0 ? 1 : multiplicity(fields, options.multiplicityColumn, lineNumber);
176
- if (count === 0) continue;
187
+ if (count === 0) return;
177
188
  order.add(source);
178
189
  order.add(target);
179
190
  if (options.mirrored === true && source !== target) {
@@ -185,7 +196,7 @@ function parseEdgeList(text, options) {
185
196
  const waiting = awaiting.get(key);
186
197
  if (waiting !== void 0 && waiting.length > 0) {
187
198
  waiting.shift();
188
- continue;
199
+ return;
189
200
  }
190
201
  const mirror = JSON.stringify([
191
202
  target,
@@ -198,12 +209,12 @@ function parseEdgeList(text, options) {
198
209
  source,
199
210
  target
200
211
  });
201
- }
212
+ });
202
213
  for (const [key, waiting] of awaiting) {
203
214
  const [first] = waiting;
204
215
  if (first !== void 0) throw new NativeParseError("edge-list", first, `the table is mirrored but this line has no mirror image (${key} is never listed)`);
205
216
  }
206
- if (edges.length === 0) throw new NativeParseError("edge-list", all.length, "the file holds no edges");
217
+ if (edges.length === 0) throw new NativeParseError("edge-list", lastLine, where === void 0 ? "the file holds no edges" : "no line of the file meets the recipe's where conditions");
207
218
  return {
208
219
  nodes: order.nodes(),
209
220
  edges
@@ -211,7 +222,7 @@ function parseEdgeList(text, options) {
211
222
  }
212
223
  //#endregion
213
224
  //#region src/parsers/gd-contest.ts
214
- const FORMAT$14 = "gd-contest";
225
+ const FORMAT$15 = "gd-contest";
215
226
  const NUMBER$9 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
216
227
  /**
217
228
  * Reads a vertex number, which the format numbers from 0 to N - 1.
@@ -220,7 +231,7 @@ const NUMBER$9 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
220
231
  * @returns The vertex id as text.
221
232
  */
222
233
  function vertex$1(token, count) {
223
- if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$14, token.line, `expected a vertex number from 0 to ${String(count - 1)}, found ${JSON.stringify(token.text)}`);
234
+ if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$15, token.line, `expected a vertex number from 0 to ${String(count - 1)}, found ${JSON.stringify(token.text)}`);
224
235
  return token.text;
225
236
  }
226
237
  /**
@@ -240,17 +251,17 @@ function parseGdContest(text) {
240
251
  });
241
252
  }
242
253
  const [first] = tokens;
243
- if (first === void 0) throw new NativeParseError(FORMAT$14, 1, "the file holds no vertex count");
244
- if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$14, first.line, `the vertex count must be a non-negative integer, found ${JSON.stringify(first.text)}`);
254
+ if (first === void 0) throw new NativeParseError(FORMAT$15, 1, "the file holds no vertex count");
255
+ if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$15, first.line, `the vertex count must be a non-negative integer, found ${JSON.stringify(first.text)}`);
245
256
  const count = Number(first.text);
246
257
  const coordinates = tokens.slice(1, 1 + 2 * count);
247
- if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$14, lines(text).length, `expected ${String(2 * count)} coordinates for ${String(count)} vertices, found ${String(coordinates.length)}`);
248
- for (const coordinate of coordinates) if (!NUMBER$9.test(coordinate.text)) throw new NativeParseError(FORMAT$14, coordinate.line, `coordinate ${JSON.stringify(coordinate.text)} is not a number`);
258
+ if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$15, lines(text).length, `expected ${String(2 * count)} coordinates for ${String(count)} vertices, found ${String(coordinates.length)}`);
259
+ for (const coordinate of coordinates) if (!NUMBER$9.test(coordinate.text)) throw new NativeParseError(FORMAT$15, coordinate.line, `coordinate ${JSON.stringify(coordinate.text)} is not a number`);
249
260
  const ends = tokens.slice(1 + 2 * count);
250
261
  const edges = [];
251
262
  for (let source = ends.shift(); source !== void 0; source = ends.shift()) {
252
263
  const target = ends.shift();
253
- if (target === void 0) throw new NativeParseError(FORMAT$14, source.line, `edge from vertex ${source.text} has no second vertex`);
264
+ if (target === void 0) throw new NativeParseError(FORMAT$15, source.line, `edge from vertex ${source.text} has no second vertex`);
254
265
  edges.push({
255
266
  source: vertex$1(source, count),
256
267
  target: vertex$1(target, count)
@@ -420,7 +431,7 @@ function parseGml(text) {
420
431
  }
421
432
  //#endregion
422
433
  //#region src/parsers/hgb.ts
423
- const FORMAT$13 = "hgb";
434
+ const FORMAT$14 = "hgb";
424
435
  /** A type code or node id as the HGB files write them: a non-negative decimal integer with no sign or leading zero. */
425
436
  const CODE = /^(?:0|[1-9]\d*)$/;
426
437
  /** A number as a decimal literal with an optional sign and exponent, so `Number` cannot read hex, an empty string or infinity. */
@@ -440,7 +451,7 @@ const LINK_FIELDS = 4;
440
451
  */
441
452
  function resolve(field, types, where) {
442
453
  const type = types[CODE.test(field) ? Number(field) : -1];
443
- if (type === void 0) throw new NativeParseError(FORMAT$13, where.line, `${where.file}: the ${where.kind} type ${JSON.stringify(field)} is not one of the ${String(types.length)} the recipe names`);
454
+ if (type === void 0) throw new NativeParseError(FORMAT$14, where.line, `${where.file}: the ${where.kind} type ${JSON.stringify(field)} is not one of the ${String(types.length)} the recipe names`);
444
455
  return type;
445
456
  }
446
457
  /**
@@ -458,9 +469,9 @@ function parseHgb(parts, options) {
458
469
  forEachLine(parts.nodes, (line, number) => {
459
470
  const fields = line.split(" ");
460
471
  const [id, , type] = fields;
461
- if (fields.length !== NODE_FIELDS && fields.length !== NODE_FIELDS_WITH_ATTRIBUTES || id === void 0 || type === void 0) throw new NativeParseError(FORMAT$13, number, `node.dat: expected ${String(NODE_FIELDS)} or ${String(NODE_FIELDS_WITH_ATTRIBUTES)} fields separated by tabs, found ${String(fields.length)}`);
462
- if (!CODE.test(id)) throw new NativeParseError(FORMAT$13, number, `node.dat: the node id ${JSON.stringify(id)} is not a non-negative integer`);
463
- if (typeOf.has(id)) throw new NativeParseError(FORMAT$13, number, `node.dat: the node id ${id} is repeated`);
472
+ if (fields.length !== NODE_FIELDS && fields.length !== NODE_FIELDS_WITH_ATTRIBUTES || id === void 0 || type === void 0) throw new NativeParseError(FORMAT$14, number, `node.dat: expected ${String(NODE_FIELDS)} or ${String(NODE_FIELDS_WITH_ATTRIBUTES)} fields separated by tabs, found ${String(fields.length)}`);
473
+ if (!CODE.test(id)) throw new NativeParseError(FORMAT$14, number, `node.dat: the node id ${JSON.stringify(id)} is not a non-negative integer`);
474
+ if (typeOf.has(id)) throw new NativeParseError(FORMAT$14, number, `node.dat: the node id ${id} is repeated`);
464
475
  const nodeType = resolve(type, options.nodeTypes, {
465
476
  file: "node.dat",
466
477
  line: number,
@@ -472,12 +483,12 @@ function parseHgb(parts, options) {
472
483
  type: nodeType
473
484
  });
474
485
  });
475
- if (nodes.length === 0) throw new NativeParseError(FORMAT$13, 1, "node.dat: the file holds no nodes");
486
+ if (nodes.length === 0) throw new NativeParseError(FORMAT$14, 1, "node.dat: the file holds no nodes");
476
487
  const edges = [];
477
488
  forEachLine(parts.links, (line, number) => {
478
489
  const fields = line.split(" ");
479
490
  const [source, target, type, weight] = fields;
480
- if (fields.length !== LINK_FIELDS || source === void 0 || target === void 0 || type === void 0 || weight === void 0) throw new NativeParseError(FORMAT$13, number, `link.dat: expected ${String(LINK_FIELDS)} fields separated by tabs, found ${String(fields.length)}`);
491
+ if (fields.length !== LINK_FIELDS || source === void 0 || target === void 0 || type === void 0 || weight === void 0) throw new NativeParseError(FORMAT$14, number, `link.dat: expected ${String(LINK_FIELDS)} fields separated by tabs, found ${String(fields.length)}`);
481
492
  const linkType = resolve(type, options.linkTypes, {
482
493
  file: "link.dat",
483
494
  line: number,
@@ -493,17 +504,17 @@ function parseHgb(parts, options) {
493
504
  linkType.target
494
505
  ]]) {
495
506
  const actual = typeOf.get(id);
496
- if (actual === void 0) throw new NativeParseError(FORMAT$13, number, `link.dat: the ${end} ${JSON.stringify(id)} is not a node of node.dat`);
497
- if (actual !== expected) throw new NativeParseError(FORMAT$13, number, `link.dat: a ${linkType.name} link's ${end} must be a ${expected} node, and ${id} is a ${actual} node`);
507
+ if (actual === void 0) throw new NativeParseError(FORMAT$14, number, `link.dat: the ${end} ${JSON.stringify(id)} is not a node of node.dat`);
508
+ if (actual !== expected) throw new NativeParseError(FORMAT$14, number, `link.dat: a ${linkType.name} link's ${end} must be a ${expected} node, and ${id} is a ${actual} node`);
498
509
  }
499
- if (!DECIMAL$5.test(weight) || Number(weight) !== 1) throw new NativeParseError(FORMAT$13, number, `link.dat: the weight ${JSON.stringify(weight)} is not 1; a weighted HGB file would lose its weights`);
510
+ if (!DECIMAL$5.test(weight) || Number(weight) !== 1) throw new NativeParseError(FORMAT$14, number, `link.dat: the weight ${JSON.stringify(weight)} is not 1; a weighted HGB file would lose its weights`);
500
511
  edges.push({
501
512
  source,
502
513
  target,
503
514
  type: linkType.name
504
515
  });
505
516
  });
506
- if (edges.length === 0) throw new NativeParseError(FORMAT$13, 1, "link.dat: the file holds no links");
517
+ if (edges.length === 0) throw new NativeParseError(FORMAT$14, 1, "link.dat: the file holds no links");
507
518
  return {
508
519
  nodes,
509
520
  edges
@@ -511,7 +522,7 @@ function parseHgb(parts, options) {
511
522
  }
512
523
  //#endregion
513
524
  //#region src/parsers/kg-triples.ts
514
- const FORMAT$12 = "kg-triples";
525
+ const FORMAT$13 = "kg-triples";
515
526
  /** The character each delimiter option splits a line on. A line is split on exactly that character, so a field cannot hide a second delimiter. */
516
527
  const DELIMITERS$1 = {
517
528
  tab: " ",
@@ -533,8 +544,8 @@ const QUOTE$2 = "\"";
533
544
  */
534
545
  function value(field, quoted, where) {
535
546
  const inner = quoted ? field.slice(1, -1) : field;
536
- if (quoted && (field.length < 2 || !field.startsWith(QUOTE$2) || !field.endsWith(QUOTE$2) || inner.includes(QUOTE$2))) throw new NativeParseError(FORMAT$12, where.line, `${where.part}the field ${JSON.stringify(field)} is not one value wrapped in double quotes`);
537
- if (inner === "") throw new NativeParseError(FORMAT$12, where.line, `${where.part}a field is empty`);
547
+ if (quoted && (field.length < 2 || !field.startsWith(QUOTE$2) || !field.endsWith(QUOTE$2) || inner.includes(QUOTE$2))) throw new NativeParseError(FORMAT$13, where.line, `${where.part}the field ${JSON.stringify(field)} is not one value wrapped in double quotes`);
548
+ if (inner === "") throw new NativeParseError(FORMAT$13, where.line, `${where.part}a field is empty`);
538
549
  return inner;
539
550
  }
540
551
  /**
@@ -558,16 +569,16 @@ function parseKgTriples(parts, options) {
558
569
  let lastLine = 0;
559
570
  forEachLine(part.pieces, (line, number) => {
560
571
  lastLine = number;
561
- if (line.trim() === "") throw new NativeParseError(FORMAT$12, number, `${label}the line is blank`);
572
+ if (line.trim() === "") throw new NativeParseError(FORMAT$13, number, `${label}the line is blank`);
562
573
  if (number === 1 && options.header === "names") return;
563
574
  if (number === 1 && options.header === "count") {
564
- if (!COUNT$1.test(line)) throw new NativeParseError(FORMAT$12, number, `${label}expected the number of triples on the first line, found ${JSON.stringify(line)}`);
575
+ if (!COUNT$1.test(line)) throw new NativeParseError(FORMAT$13, number, `${label}expected the number of triples on the first line, found ${JSON.stringify(line)}`);
565
576
  declared = Number(line);
566
577
  return;
567
578
  }
568
579
  const fields = line.split(delimiter);
569
580
  const [head, middle, last] = fields;
570
- if (fields.length !== TRIPLE_FIELDS || head === void 0 || middle === void 0 || last === void 0) throw new NativeParseError(FORMAT$12, number, `${label}expected ${String(TRIPLE_FIELDS)} fields separated by ${options.delimiter}, found ${String(fields.length)}`);
581
+ if (fields.length !== TRIPLE_FIELDS || head === void 0 || middle === void 0 || last === void 0) throw new NativeParseError(FORMAT$13, number, `${label}expected ${String(TRIPLE_FIELDS)} fields separated by ${options.delimiter}, found ${String(fields.length)}`);
571
582
  const where = {
572
583
  line: number,
573
584
  part: label
@@ -593,8 +604,8 @@ function parseKgTriples(parts, options) {
593
604
  });
594
605
  triples += 1;
595
606
  });
596
- if (triples === 0) throw new NativeParseError(FORMAT$12, lastLine, `${label}the file holds no triples`);
597
- if (declared !== void 0 && declared !== triples) throw new NativeParseError(FORMAT$12, lastLine, `${label}the first line declares ${String(declared)} triples, the file holds ${String(triples)}`);
607
+ if (triples === 0) throw new NativeParseError(FORMAT$13, lastLine, `${label}the file holds no triples`);
608
+ if (declared !== void 0 && declared !== triples) throw new NativeParseError(FORMAT$13, lastLine, `${label}the first line declares ${String(declared)} triples, the file holds ${String(triples)}`);
598
609
  }
599
610
  return {
600
611
  nodes: order.nodes(),
@@ -603,7 +614,7 @@ function parseKgTriples(parts, options) {
603
614
  }
604
615
  //#endregion
605
616
  //#region src/parsers/konect.ts
606
- const FORMAT$11 = "konect";
617
+ const FORMAT$12 = "konect";
607
618
  /** The network formats of KONECT's first line (Kunegis, "The KONECT Handbook", table 1): undirected, directed and bipartite. */
608
619
  const NETWORK_FORMATS = /* @__PURE__ */ new Set([
609
620
  "sym",
@@ -678,9 +689,9 @@ function parseKonect(pieces, options) {
678
689
  }));
679
690
  });
680
691
  const { header } = state;
681
- if (header === void 0) throw new NativeParseError(FORMAT$11, lastLine, "the file has no first line naming its format and weights");
682
- if (edges.length === 0) throw new NativeParseError(FORMAT$11, lastLine, "the file holds no edges");
683
- if (header.counts !== void 0 && header.counts.edges !== edges.length) throw new NativeParseError(FORMAT$11, lastLine, `the second line declares ${String(header.counts.edges)} edges, the file holds ${String(edges.length)}`);
692
+ if (header === void 0) throw new NativeParseError(FORMAT$12, lastLine, "the file has no first line naming its format and weights");
693
+ if (edges.length === 0) throw new NativeParseError(FORMAT$12, lastLine, "the file holds no edges");
694
+ if (header.counts !== void 0 && header.counts.edges !== edges.length) throw new NativeParseError(FORMAT$12, lastLine, `the second line declares ${String(header.counts.edges)} edges, the file holds ${String(edges.length)}`);
684
695
  return {
685
696
  nodes: order.nodes(),
686
697
  edges
@@ -698,9 +709,9 @@ function readHeader(content, number, options) {
698
709
  const match = FORMAT_LINE.exec(content);
699
710
  const network = match?.[1];
700
711
  const weights = match?.[2];
701
- if (network === void 0 || weights === void 0 || !NETWORK_FORMATS.has(network)) throw new NativeParseError(FORMAT$11, number, `expected a first line "% FORMAT WEIGHTS" with FORMAT one of ${[...NETWORK_FORMATS].join(", ")}, found ${JSON.stringify(content)}`);
702
- if (!WEIGHTED_TYPES.has(weights) && !UNWEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$11, number, `the weight type ${JSON.stringify(weights)} is not one this parser reads (its third column is not an edge weight or a repetition count)`);
703
- if (options.weighted !== WEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$11, number, `the file's weight type is ${weights}, so a recipe with weighted ${String(WEIGHTED_TYPES.has(weights))} reads it, not one with weighted ${String(options.weighted)}`);
712
+ if (network === void 0 || weights === void 0 || !NETWORK_FORMATS.has(network)) throw new NativeParseError(FORMAT$12, number, `expected a first line "% FORMAT WEIGHTS" with FORMAT one of ${[...NETWORK_FORMATS].join(", ")}, found ${JSON.stringify(content)}`);
713
+ if (!WEIGHTED_TYPES.has(weights) && !UNWEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$12, number, `the weight type ${JSON.stringify(weights)} is not one this parser reads (its third column is not an edge weight or a repetition count)`);
714
+ if (options.weighted !== WEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$12, number, `the file's weight type is ${weights}, so a recipe with weighted ${String(WEIGHTED_TYPES.has(weights))} reads it, not one with weighted ${String(options.weighted)}`);
704
715
  return {
705
716
  line: number,
706
717
  network
@@ -720,15 +731,15 @@ function readEdge(content, number, context) {
720
731
  const [from, to, third, time] = fields;
721
732
  const expected = options.timestamped ? TIMESTAMPED_COLUMNS : options.weighted ? WEIGHTED_COLUMNS : void 0;
722
733
  const fits = expected === void 0 ? fields.length === ENDPOINT_COLUMNS || fields.length === WEIGHTED_COLUMNS : fields.length === expected;
723
- if (from === void 0 || to === void 0 || !fits) throw new NativeParseError(FORMAT$11, number, `expected ${expected === void 0 ? `${String(ENDPOINT_COLUMNS)} or ${String(WEIGHTED_COLUMNS)}` : String(expected)} columns for this recipe, found ${String(fields.length)}`);
734
+ if (from === void 0 || to === void 0 || !fits) throw new NativeParseError(FORMAT$12, number, `expected ${expected === void 0 ? `${String(ENDPOINT_COLUMNS)} or ${String(WEIGHTED_COLUMNS)}` : String(expected)} columns for this recipe, found ${String(fields.length)}`);
724
735
  const bipartite = header.network === "bip";
725
736
  const source = endpoint(from, bipartite ? LEFT_PREFIX : "", header.counts?.left, number);
726
737
  const target = endpoint(to, bipartite ? RIGHT_PREFIX : "", header.counts?.right, number);
727
738
  order.add(source);
728
739
  order.add(target);
729
- if (third !== void 0 && !options.weighted && (!DECIMAL$4.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$11, number, `the third column ${JSON.stringify(third)} is not 1; an edge that stands for several would be read as one`);
730
- if (third !== void 0 && options.weighted && !DECIMAL$4.test(third)) throw new NativeParseError(FORMAT$11, number, `the weight ${JSON.stringify(third)} is not a number`);
731
- if (time !== void 0 && !DECIMAL$4.test(time)) throw new NativeParseError(FORMAT$11, number, `the time ${JSON.stringify(time)} is not a number`);
740
+ if (third !== void 0 && !options.weighted && (!DECIMAL$4.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$12, number, `the third column ${JSON.stringify(third)} is not 1; an edge that stands for several would be read as one`);
741
+ if (third !== void 0 && options.weighted && !DECIMAL$4.test(third)) throw new NativeParseError(FORMAT$12, number, `the weight ${JSON.stringify(third)} is not a number`);
742
+ if (time !== void 0 && !DECIMAL$4.test(time)) throw new NativeParseError(FORMAT$12, number, `the time ${JSON.stringify(time)} is not a number`);
732
743
  const edge = {
733
744
  source,
734
745
  target
@@ -747,10 +758,215 @@ function readEdge(content, number, context) {
747
758
  * @throws NativeParseError when the token is not a positive integer or exceeds the set's size.
748
759
  */
749
760
  function endpoint(token, prefix, size, number) {
750
- if (!NODE_ID.test(token) || size !== void 0 && Number(token) > size) throw new NativeParseError(FORMAT$11, number, `${JSON.stringify(token)} is not a node id from 1${size === void 0 ? "" : ` to ${String(size)}`}`);
761
+ if (!NODE_ID.test(token) || size !== void 0 && Number(token) > size) throw new NativeParseError(FORMAT$12, number, `${JSON.stringify(token)} is not a node id from 1${size === void 0 ? "" : ` to ${String(size)}`}`);
751
762
  return `${prefix}${token}`;
752
763
  }
753
764
  //#endregion
765
+ //#region src/parsers/ogb.ts
766
+ const FORMAT$11 = "ogb";
767
+ /** The files of an OGB raw directory this reader takes, by their path under the dataset's root directory. A relation's files sit in `raw/relations/<head>___<relation>___<tail>/`. */
768
+ const RAW = /^(?<root>(?:[^/]+\/)*?)raw\/(?<name>num-node-list|num-node-dict|edge|num-edge-list|triplet-type-list|relations\/[^/]+\/(?:edge|num-edge-list))\.csv\.gz$/u;
769
+ const INDEX = /^(?:0|[1-9]\d*)$/;
770
+ /** The fields of a line of `triplet-type-list.csv.gz`: a head type, a relation and a tail type. */
771
+ const TRIPLET_PARTS = 3;
772
+ /**
773
+ * Orders two names by code point, the order Python's `sorted` gives the ASCII type and relation names OGB uses.
774
+ * @param left - A name.
775
+ * @param right - Another.
776
+ * @returns Negative, zero or positive.
777
+ */
778
+ function compare(left, right) {
779
+ return left < right ? -1 : left > right ? 1 : 0;
780
+ }
781
+ /**
782
+ * Visits the non-blank lines of a file, read a piece at a time.
783
+ * @param file - The file.
784
+ * @param visit - Called with each non-blank line and its number.
785
+ */
786
+ function forEachRow(file, visit) {
787
+ forEachLine(file.text(), (text, line) => {
788
+ if (text.trim() !== "") visit({
789
+ text,
790
+ line
791
+ });
792
+ });
793
+ }
794
+ /**
795
+ * The non-blank lines of a small file, such as a count or the triplet list.
796
+ * @param file - The file.
797
+ * @returns Each non-blank line and its number.
798
+ */
799
+ function rows(file) {
800
+ const all = [];
801
+ forEachRow(file, (row) => {
802
+ all.push(row);
803
+ });
804
+ return all;
805
+ }
806
+ /**
807
+ * Reads a file that holds one line, such as a count.
808
+ * @param file - The file.
809
+ * @returns The line's text.
810
+ * @throws NativeParseError when the file holds no line or more than one.
811
+ */
812
+ function onlyLine(file) {
813
+ const all = rows(file);
814
+ const [only] = all;
815
+ if (all.length !== 1 || only === void 0) throw new NativeParseError(FORMAT$11, all[1]?.line ?? 1, `${file.path} must hold one line, found ${String(all.length)}`);
816
+ return only.text.trim();
817
+ }
818
+ /**
819
+ * Reads a non-negative integer.
820
+ * @param text - The field.
821
+ * @param file - The file, for the error.
822
+ * @param line - The 1-based line, for the error.
823
+ * @returns The integer.
824
+ * @throws NativeParseError when the field is not a non-negative integer.
825
+ */
826
+ function index(text, file, line) {
827
+ const trimmed = text.trim();
828
+ if (!INDEX.test(trimmed)) throw new NativeParseError(FORMAT$11, line, `${file.path}: expected a non-negative integer, found ${JSON.stringify(text)}`);
829
+ return Number(trimmed);
830
+ }
831
+ /**
832
+ * Reads an `edge.csv.gz` file, one `<source>,<target>` pair of node indices per line, checking each index against its node type's count and the line count against the relation's `num-edge-list.csv.gz`.
833
+ * @param edges - The edge file.
834
+ * @param count - The relation's `num-edge-list.csv.gz`.
835
+ * @param ends - The source and target node types' counts and how each index is named.
836
+ * @param emit - Called with each edge, in file order, so a large file is never held twice.
837
+ * @throws NativeParseError naming the file and line for a line that is not a pair of indices, an index outside its type's nodes, or a line count other than the recorded one.
838
+ */
839
+ function readEdges(edges, count, ends, emit) {
840
+ const recorded = index(onlyLine(count), count, 1);
841
+ let read = 0;
842
+ forEachRow(edges, ({ text, line }) => {
843
+ const fields = text.split(",");
844
+ const [first, second] = fields;
845
+ if (fields.length !== 2 || first === void 0 || second === void 0) throw new NativeParseError(FORMAT$11, line, `${edges.path}: expected a source and a target index, found ${JSON.stringify(text)}`);
846
+ const source = index(first, edges, line);
847
+ const target = index(second, edges, line);
848
+ if (source >= ends.sources || target >= ends.targets) throw new NativeParseError(FORMAT$11, line, `${edges.path}: edge ${text} names a node outside the ${String(ends.sources)} sources and ${String(ends.targets)} targets the dataset records`);
849
+ emit({
850
+ source: ends.source(source),
851
+ target: ends.target(target)
852
+ });
853
+ read += 1;
854
+ });
855
+ if (read !== recorded) throw new NativeParseError(FORMAT$11, read, `${edges.path} holds ${String(read)} edges, but ${count.path} records ${String(recorded)}`);
856
+ }
857
+ /**
858
+ * Takes one file of the dataset directory by its name under `raw/`.
859
+ * @param files - The files by name.
860
+ * @param name - The name, such as `edge` or `relations/paper___cites___paper/edge`.
861
+ * @param root - The dataset's root directory, for the error.
862
+ * @returns The file.
863
+ * @throws NativeParseError when the archive's selection holds no such file.
864
+ */
865
+ function required$1(files, name, root) {
866
+ const file = files.get(name);
867
+ if (file === void 0) throw new NativeParseError(FORMAT$11, 1, `the dataset directory ${root} has no raw/${name}.csv.gz among the files the recipe selects`);
868
+ return file;
869
+ }
870
+ /**
871
+ * Parses an Open Graph Benchmark dataset from the files of its raw directory, as OGB's own `read_csv_graph_raw` and `read_csv_heterograph_raw` read them (ogb/io/read_graph_raw.py in snap-stanford/ogb). A homogeneous dataset has `raw/num-node-list.csv.gz` (its one graph's node count), `raw/edge.csv.gz` (one `<source>,<target>` pair of node indices per line, no header) and `raw/num-edge-list.csv.gz`: its nodes are `0` to `N - 1`, isolated ones included, and its edges the pairs in file order. A heterogeneous dataset has `raw/num-node-dict.csv.gz` (a header of node types over one row of their counts), `raw/triplet-type-list.csv.gz` (one `<head>,<relation>,<tail>` type triplet per line) and, per triplet, `raw/relations/<head>___<relation>___<tail>/edge.csv.gz` and `num-edge-list.csv.gz`, whose indices count within the head and tail types: its nodes are `<type>:<index>` for each type in sorted order and each index from 0, and its edges are each relation's pairs in file order, relations in the sorted order of their triplets, which is the order OGB reads them in. Edges keep the file's direction. Relation names, node features, labels and splits are not carried, as no graph parser in this version carries more than topology.
872
+ * @param files - The files the recipe's archive selects, gunzipped and decoded: every one must be a file of one dataset's raw directory named above.
873
+ * @returns The native graph.
874
+ * @throws NativeParseError naming the file and line for a file outside the raw layout, files of two datasets, a dataset with neither or both node-count files, a missing file, a graph count other than one, a malformed count or triplet, a relation directory the triplet list does not name, an edge that is not a pair of indices of the dataset's nodes, or an edge file whose line count differs from its recorded count.
875
+ */
876
+ function parseOgb(files) {
877
+ const byName = /* @__PURE__ */ new Map();
878
+ const roots = /* @__PURE__ */ new Set();
879
+ for (const file of files) {
880
+ const match = RAW.exec(file.path);
881
+ const name = match?.groups?.["name"];
882
+ const root = match?.groups?.["root"];
883
+ if (name === void 0 || root === void 0) throw new NativeParseError(FORMAT$11, 1, `${file.path} is not a file of an OGB raw directory this reader takes`);
884
+ roots.add(root);
885
+ byName.set(name, file);
886
+ }
887
+ const [root] = roots;
888
+ if (roots.size !== 1 || root === void 0) throw new NativeParseError(FORMAT$11, 1, `the recipe must select the raw files of one dataset, found ${roots.size === 0 ? "none" : `the directories ${[...roots].join(", ")}`}`);
889
+ const homogeneous = byName.get("num-node-list");
890
+ const heterogeneous = byName.get("num-node-dict");
891
+ const neitherOrBoth = `the dataset directory ${root} must have exactly one of raw/num-node-list.csv.gz (one node type) and raw/num-node-dict.csv.gz (several)`;
892
+ const edges = [];
893
+ if (homogeneous !== void 0) {
894
+ if (heterogeneous !== void 0) throw new NativeParseError(FORMAT$11, 1, neitherOrBoth);
895
+ const [relation] = [...byName.keys()].filter((name) => name.startsWith("relations/"));
896
+ if (relation !== void 0 || byName.has("triplet-type-list")) throw new NativeParseError(FORMAT$11, 1, `the dataset directory ${root} has raw/num-node-list.csv.gz, so it has one node type, but the recipe selects ${relation === void 0 ? "raw/triplet-type-list.csv.gz" : `raw/${relation}.csv.gz`} as well`);
897
+ const count = index(onlyLine(homogeneous), homogeneous, 1);
898
+ const name = (node) => String(node);
899
+ readEdges(required$1(byName, "edge", root), required$1(byName, "num-edge-list", root), {
900
+ sources: count,
901
+ targets: count,
902
+ source: name,
903
+ target: name
904
+ }, (edge) => {
905
+ edges.push(edge);
906
+ });
907
+ return {
908
+ nodes: Array.from({ length: count }, (_, node) => ({ id: String(node) })),
909
+ edges
910
+ };
911
+ }
912
+ if (heterogeneous === void 0) throw new NativeParseError(FORMAT$11, 1, neitherOrBoth);
913
+ for (const stray of ["edge", "num-edge-list"]) if (byName.has(stray)) throw new NativeParseError(FORMAT$11, 1, `the dataset directory ${root} has raw/num-node-dict.csv.gz, so its edges are in raw/relations/, but the recipe selects raw/${stray}.csv.gz as well`);
914
+ const header = rows(heterogeneous);
915
+ const [types, counts] = header;
916
+ if (header.length !== 2 || types === void 0 || counts === void 0) throw new NativeParseError(FORMAT$11, header[2]?.line ?? 1, `${heterogeneous.path} must hold a header of node types and one row of their counts, found ${String(header.length)} lines`);
917
+ const typeNames = types.text.split(",").map((type) => type.trim());
918
+ const typeCounts = counts.text.split(",");
919
+ if (typeNames.length !== typeCounts.length || typeNames.some((type) => type === "") || new Set(typeNames).size !== typeNames.length) throw new NativeParseError(FORMAT$11, counts.line, `${heterogeneous.path} must name each node type once, over one count per type`);
920
+ const nodeTypes = typeNames.map((type, position) => {
921
+ const count = typeCounts[position];
922
+ if (count === void 0) throw new NativeParseError(FORMAT$11, counts.line, `${heterogeneous.path} gives node type ${type} no count`);
923
+ return {
924
+ type,
925
+ count: index(count, heterogeneous, counts.line)
926
+ };
927
+ });
928
+ nodeTypes.sort((left, right) => compare(left.type, right.type));
929
+ const nodeCounts = new Map(nodeTypes.map(({ type, count }) => [type, count]));
930
+ const triplets = required$1(byName, "triplet-type-list", root);
931
+ const relations = [];
932
+ for (const { text, line } of rows(triplets)) {
933
+ const parts = text.split(",").map((part) => part.trim());
934
+ const [head, relation, tail] = parts;
935
+ if (parts.length !== TRIPLET_PARTS || head === void 0 || relation === void 0 || tail === void 0 || relation === "") throw new NativeParseError(FORMAT$11, line, `${triplets.path}: expected a head type, a relation and a tail type, found ${JSON.stringify(text)}`);
936
+ const sources = nodeCounts.get(head);
937
+ const targets = nodeCounts.get(tail);
938
+ if (sources === void 0 || targets === void 0) throw new NativeParseError(FORMAT$11, line, `${triplets.path}: ${text} names a node type that ${heterogeneous.path} does not count`);
939
+ relations.push({
940
+ head,
941
+ relation,
942
+ tail,
943
+ sources,
944
+ targets
945
+ });
946
+ }
947
+ relations.sort((left, right) => compare(left.head, right.head) || compare(left.relation, right.relation) || compare(left.tail, right.tail));
948
+ const listed = new Set(relations.map(({ head, relation, tail }) => `relations/${head}___${relation}___${tail}`));
949
+ for (const name of byName.keys()) {
950
+ const directory = name.slice(0, name.lastIndexOf("/"));
951
+ if (name.startsWith("relations/") && !listed.has(directory)) throw new NativeParseError(FORMAT$11, 1, `raw/${directory}/ is not one of the relations ${triplets.path} lists`);
952
+ }
953
+ for (const { head, relation, tail, sources, targets } of relations) {
954
+ const directory = `relations/${head}___${relation}___${tail}`;
955
+ readEdges(required$1(byName, `${directory}/edge`, root), required$1(byName, `${directory}/num-edge-list`, root), {
956
+ sources,
957
+ targets,
958
+ source: (node) => `${head}:${String(node)}`,
959
+ target: (node) => `${tail}:${String(node)}`
960
+ }, (edge) => {
961
+ edges.push(edge);
962
+ });
963
+ }
964
+ return {
965
+ nodes: nodeTypes.flatMap(({ type, count }) => Array.from({ length: count }, (_, node) => ({ id: `${type}:${String(node)}` }))),
966
+ edges
967
+ };
968
+ }
969
+ //#endregion
754
970
  //#region src/parsers/matrix-market.ts
755
971
  /** How many tokens an entry line of each field type holds: the coordinates, then the value's parts (none for a pattern, a real and an imaginary part for a complex value). */
756
972
  const tokensPerEntry = /* @__PURE__ */ new Map([
@@ -2978,9 +3194,9 @@ function parseNative(bytes, parser) {
2978
3194
  pieces: decodePieces(bytes, parser)
2979
3195
  }], parser);
2980
3196
  if (parser.format === "hgb") throw new NativeParseError(parser.format, 1, "an HGB dataset is two files, nodes and links, which a recipe reads from a parts archive");
3197
+ if (parser.format === "edge-list") return parseEdgeList(decodePieces(bytes, parser), parser);
2981
3198
  const text = decode(bytes, parser);
2982
3199
  if (parser.format === "geojson") return parseGeojson(text, parser);
2983
- if (parser.format === "edge-list") return parseEdgeList(text, parser);
2984
3200
  if (parser.format === "matrix-market") return parseMatrixMarket(text);
2985
3201
  if (parser.format === "gml") return parseGml(text);
2986
3202
  if (parser.format === "ucinet-dl") return parseUcinetDl(text, parser);
@@ -2994,6 +3210,7 @@ function parseNative(bytes, parser) {
2994
3210
  if (parser.format === "verolog-2019") return parseVerolog2019(text);
2995
3211
  if (parser.format === "vrp-rep") return parseVrpRep(text);
2996
3212
  if (parser.format === "hhcrsp") return parseHhcrsp(text);
3213
+ if (parser.format === "ogb") throw new NativeParseError(parser.format, 1, "the format reads the several files a files archive selects, not one file");
2997
3214
  return parsePajek(text);
2998
3215
  }
2999
3216
  /**
@@ -3019,6 +3236,20 @@ function parseNativeParts(parts, parser) {
3019
3236
  }
3020
3237
  throw new NativeParseError(parser.format, 1, `the ${parser.format} parser reads one file, not named parts`);
3021
3238
  }
3239
+ /**
3240
+ * Reads the several files of a multi-file format into the one native document they make together.
3241
+ * @param files - The files a `files` archive selects, each with its path in the archive, in path order.
3242
+ * @param parser - The recipe's parser, which must be a multi-file format.
3243
+ * @returns The native graph.
3244
+ * @throws NativeParseError when a file is not valid in the stated encoding or the files are not in the stated format, or when the parser reads one file and not several.
3245
+ */
3246
+ function parseNativeFiles(files, parser) {
3247
+ if (parser.format !== "ogb") throw new NativeParseError(parser.format, 1, "the format reads one file, not the several a files archive selects");
3248
+ return parseOgb(files.map((file) => ({
3249
+ path: file.path,
3250
+ text: () => decodePieces(file.bytes, parser)
3251
+ })));
3252
+ }
3022
3253
  //#endregion
3023
3254
  //#region src/node/bzip2.ts
3024
3255
  /** The four bytes every bzip2 stream starts with, as ASCII: `BZh` and the block size digit, `1` to `9`. */
@@ -4912,6 +5143,30 @@ function sheetToCsv(bytes, archive) {
4912
5143
  return gridToCsv(read(bytes, archive.sheet));
4913
5144
  }
4914
5145
  /**
5146
+ * Unpacks downloaded bytes into every file a multi-file format's selector matches, gunzipping each when the layout's `inner` says so. The paths stay the archive's own, so a gunzipped `edge.csv.gz` keeps its name.
5147
+ * @param bytes - The downloaded bytes.
5148
+ * @param archive - The recipe's archive layout, which selects files with `files`.
5149
+ * @returns Each matching file, in path order.
5150
+ * @throws ArchiveError when the bytes, or a file `inner` gunzips, are not well-formed, or the selector matches no member.
5151
+ */
5152
+ function unpackFiles(bytes, archive) {
5153
+ const files = unpackMembers(bytes, {
5154
+ kind: archive.kind,
5155
+ members: archive.files
5156
+ });
5157
+ if (archive.inner === void 0) return files;
5158
+ return files.map((file) => {
5159
+ try {
5160
+ return {
5161
+ path: file.path,
5162
+ bytes: gunzip(file.bytes)
5163
+ };
5164
+ } catch (error) {
5165
+ throw new ArchiveError(`member ${file.path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
5166
+ }
5167
+ });
5168
+ }
5169
+ /**
4915
5170
  * Gunzips bytes, rethrowing zlib's failure in the package's taxonomy.
4916
5171
  * @param bytes - Gzip data.
4917
5172
  * @returns The decompressed bytes.
@@ -5450,6 +5705,20 @@ async function runToNative(dataset, options) {
5450
5705
  provenance: provenanceOf({ members: parts.map((part) => part.path) })
5451
5706
  };
5452
5707
  }
5708
+ if ("files" in archive) {
5709
+ const files = await step(entry.id, "extract", () => unpackFiles(bytes, archive));
5710
+ return {
5711
+ collection: false,
5712
+ documents: [{
5713
+ id: void 0,
5714
+ native: {
5715
+ ...await step(entry.id, "parse", () => parseNativeFiles(files, recipe.parser)),
5716
+ meta: metaOf(entry)
5717
+ }
5718
+ }],
5719
+ provenance: provenanceOf({ members: files.map((file) => file.path) })
5720
+ };
5721
+ }
5453
5722
  const unpacked = await step(entry.id, "extract", () => unpack(bytes, archive));
5454
5723
  const parsed = await step(entry.id, "parse", () => parseNative(unpacked.bytes, recipe.parser));
5455
5724
  return {
@@ -4,7 +4,7 @@ import { KeepsetError, NAME_SEPARATOR, countTarget, getDataset, listDatasets, li
4
4
  import { fetchDataset, roundTripDataset } from "keepset/node";
5
5
  import { parseArgs } from "node:util";
6
6
  //#region package.json
7
- var version = "0.38.0";
7
+ var version = "0.39.0";
8
8
  //#endregion
9
9
  //#region cli/run.ts
10
10
  /**
@@ -136,7 +136,7 @@ function formatShow(dataset) {
136
136
  else {
137
137
  const { archive } = recipe;
138
138
  const innerText = "inner" in archive && archive.inner !== void 0 ? `, inner ${archive.inner.kind}${"member" in archive.inner ? `, member ${selectorText(archive.inner.member)}` : ""}` : "";
139
- const memberText = "member" in archive ? `, member ${selectorText(archive.member)}${innerText}` : "members" in archive ? `, members ${selectorText(archive.members)}` : "parts" in archive ? `, parts ${archive.parts.map((part) => `${part.name} from ${selectorText(part.member)}`).join(", ")}` : "";
139
+ const memberText = "member" in archive ? `, member ${selectorText(archive.member)}${innerText}` : "members" in archive ? `, members ${selectorText(archive.members)}` : "parts" in archive ? `, parts ${archive.parts.map((part) => `${part.name} from ${selectorText(part.member)}`).join(", ")}` : "files" in archive ? `, files ${selectorText(archive.files)}${innerText}` : "";
140
140
  const { format, ...options } = recipe.parser;
141
141
  lines.push(` source: [${String(recipe.source)}]`, ` archive: ${archive.kind}${memberText}`, ` parser: ${format} ${JSON.stringify(options)}`);
142
142
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "keepset",
3
3
  "description": "Library, CLI and statically generated catalogue for dataset fetch-and-normalise recipes",
4
- "version": "0.38.0",
4
+ "version": "0.39.0",
5
5
  "bin": {
6
6
  "keepset": "./dist-cli/keepset.mjs"
7
7
  },