keepset 0.32.0 → 0.34.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/README.md +1 -1
- package/dist/{bundled-3xBd8sP5.js → bundled-CPSe9InV.js} +3114 -736
- package/dist/{categories-HwqAn-Lc.d.ts → categories-DUG7URRd.d.ts} +149 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node/fetch-dataset.d.ts +1 -1
- package/dist/node/fetch-dataset.js +416 -95
- package/dist-cli/keepset.mjs +1 -1
- package/package.json +4 -1
- package/schemas/entry.schema.json +1 -1
- package/schemas/manifest.schema.json +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
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-
|
|
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-CPSe9InV.js";
|
|
2
2
|
import { RoundTripMismatchError, assertRoundTrip, encode } from "crossmap";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { gunzipSync } from "node:zlib";
|
|
6
6
|
import { unzipSync } from "fflate";
|
|
7
|
+
import unbzip2 from "unbzip2-stream";
|
|
7
8
|
import { request } from "node:http";
|
|
8
9
|
import { request as request$1 } from "node:https";
|
|
9
10
|
//#region src/parsers/native.ts
|
|
@@ -36,6 +37,30 @@ var NodeOrder = class {
|
|
|
36
37
|
function lines(text) {
|
|
37
38
|
return text.split(/\r?\n/);
|
|
38
39
|
}
|
|
40
|
+
const CARRIAGE_RETURN$1 = 13;
|
|
41
|
+
/**
|
|
42
|
+
* Visits the lines of a file read in pieces, accepting both LF and CRLF endings, holding only the unfinished end of a piece between pieces so a file larger than one string can be read.
|
|
43
|
+
* @param pieces - The file's text, in order, split anywhere (even between a carriage return and its line feed).
|
|
44
|
+
* @param visit - Called with each line, without its terminator, and its 1-based number. A final line without a terminator is visited; the empty text after a final terminator is not a line.
|
|
45
|
+
*/
|
|
46
|
+
function forEachLine(pieces, visit) {
|
|
47
|
+
let carry = "";
|
|
48
|
+
let number = 0;
|
|
49
|
+
const emit = (text, start, end) => {
|
|
50
|
+
number += 1;
|
|
51
|
+
visit(end > start && text.charCodeAt(end - 1) === CARRIAGE_RETURN$1 ? text.slice(start, end - 1) : text.slice(start, end), number);
|
|
52
|
+
};
|
|
53
|
+
for (const piece of pieces) {
|
|
54
|
+
const text = carry + piece;
|
|
55
|
+
let start = 0;
|
|
56
|
+
for (let end = text.indexOf("\n", start); end !== -1; end = text.indexOf("\n", start)) {
|
|
57
|
+
emit(text, start, end);
|
|
58
|
+
start = end + 1;
|
|
59
|
+
}
|
|
60
|
+
carry = text.slice(start);
|
|
61
|
+
}
|
|
62
|
+
if (carry !== "") emit(carry, 0, carry.length);
|
|
63
|
+
}
|
|
39
64
|
//#endregion
|
|
40
65
|
//#region src/parsers/edge-list.ts
|
|
41
66
|
/** How each fixed delimiter option splits a line into fields. */
|
|
@@ -53,7 +78,7 @@ const splitters = {
|
|
|
53
78
|
* @returns The endpoint id.
|
|
54
79
|
* @throws NativeParseError when the column is absent or empty.
|
|
55
80
|
*/
|
|
56
|
-
function endpoint(fields, column, lineNumber) {
|
|
81
|
+
function endpoint$1(fields, column, lineNumber) {
|
|
57
82
|
const value = fields[column];
|
|
58
83
|
if (value === void 0 || value.trim() === "") throw new NativeParseError("edge-list", lineNumber, `column ${String(column)} holds no node id`);
|
|
59
84
|
return value.trim();
|
|
@@ -86,8 +111,8 @@ function parseEdgeList(text, options) {
|
|
|
86
111
|
split ??= splitters[detect(line)];
|
|
87
112
|
const fields = split(line);
|
|
88
113
|
if (fields.length < needed) throw new NativeParseError("edge-list", lineNumber, `expected at least ${String(needed)} fields, found ${String(fields.length)}`);
|
|
89
|
-
const source = endpoint(fields, options.sourceColumn, lineNumber);
|
|
90
|
-
const target = endpoint(fields, options.targetColumn, lineNumber);
|
|
114
|
+
const source = endpoint$1(fields, options.sourceColumn, lineNumber);
|
|
115
|
+
const target = endpoint$1(fields, options.targetColumn, lineNumber);
|
|
91
116
|
order.add(source);
|
|
92
117
|
order.add(target);
|
|
93
118
|
edges.push({
|
|
@@ -103,7 +128,7 @@ function parseEdgeList(text, options) {
|
|
|
103
128
|
}
|
|
104
129
|
//#endregion
|
|
105
130
|
//#region src/parsers/gd-contest.ts
|
|
106
|
-
const FORMAT$
|
|
131
|
+
const FORMAT$10 = "gd-contest";
|
|
107
132
|
const NUMBER$8 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
|
|
108
133
|
/**
|
|
109
134
|
* Reads a vertex number, which the format numbers from 0 to N - 1.
|
|
@@ -112,7 +137,7 @@ const NUMBER$8 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[Ee][+-]?\d+)?$/;
|
|
|
112
137
|
* @returns The vertex id as text.
|
|
113
138
|
*/
|
|
114
139
|
function vertex$1(token, count) {
|
|
115
|
-
if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$
|
|
140
|
+
if (!/^(?:0|[1-9]\d*)$/.test(token.text) || Number(token.text) >= count) throw new NativeParseError(FORMAT$10, token.line, `expected a vertex number from 0 to ${String(count - 1)}, found ${JSON.stringify(token.text)}`);
|
|
116
141
|
return token.text;
|
|
117
142
|
}
|
|
118
143
|
/**
|
|
@@ -132,17 +157,17 @@ function parseGdContest(text) {
|
|
|
132
157
|
});
|
|
133
158
|
}
|
|
134
159
|
const [first] = tokens;
|
|
135
|
-
if (first === void 0) throw new NativeParseError(FORMAT$
|
|
136
|
-
if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$
|
|
160
|
+
if (first === void 0) throw new NativeParseError(FORMAT$10, 1, "the file holds no vertex count");
|
|
161
|
+
if (!/^(?:0|[1-9]\d*)$/.test(first.text)) throw new NativeParseError(FORMAT$10, first.line, `the vertex count must be a non-negative integer, found ${JSON.stringify(first.text)}`);
|
|
137
162
|
const count = Number(first.text);
|
|
138
163
|
const coordinates = tokens.slice(1, 1 + 2 * count);
|
|
139
|
-
if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$
|
|
140
|
-
for (const coordinate of coordinates) if (!NUMBER$8.test(coordinate.text)) throw new NativeParseError(FORMAT$
|
|
164
|
+
if (coordinates.length < 2 * count) throw new NativeParseError(FORMAT$10, lines(text).length, `expected ${String(2 * count)} coordinates for ${String(count)} vertices, found ${String(coordinates.length)}`);
|
|
165
|
+
for (const coordinate of coordinates) if (!NUMBER$8.test(coordinate.text)) throw new NativeParseError(FORMAT$10, coordinate.line, `coordinate ${JSON.stringify(coordinate.text)} is not a number`);
|
|
141
166
|
const ends = tokens.slice(1 + 2 * count);
|
|
142
167
|
const edges = [];
|
|
143
168
|
for (let source = ends.shift(); source !== void 0; source = ends.shift()) {
|
|
144
169
|
const target = ends.shift();
|
|
145
|
-
if (target === void 0) throw new NativeParseError(FORMAT$
|
|
170
|
+
if (target === void 0) throw new NativeParseError(FORMAT$10, source.line, `edge from vertex ${source.text} has no second vertex`);
|
|
146
171
|
edges.push({
|
|
147
172
|
source: vertex$1(source, count),
|
|
148
173
|
target: vertex$1(target, count)
|
|
@@ -311,6 +336,155 @@ function parseGml(text) {
|
|
|
311
336
|
};
|
|
312
337
|
}
|
|
313
338
|
//#endregion
|
|
339
|
+
//#region src/parsers/konect.ts
|
|
340
|
+
const FORMAT$9 = "konect";
|
|
341
|
+
/** The network formats of KONECT's first line (Kunegis, "The KONECT Handbook", table 1): undirected, directed and bipartite. */
|
|
342
|
+
const NETWORK_FORMATS = /* @__PURE__ */ new Set([
|
|
343
|
+
"sym",
|
|
344
|
+
"asym",
|
|
345
|
+
"bip"
|
|
346
|
+
]);
|
|
347
|
+
/** The weight types whose edges carry no weight: a third column, where the file has one, counts the edge's repetitions and is 1 for a single edge. */
|
|
348
|
+
const UNWEIGHTED_TYPES = /* @__PURE__ */ new Set(["unweighted", "positive"]);
|
|
349
|
+
/** The weight types whose third column is the edge's weight (handbook table 2). `dynamic` is not among them: its third column is +1 for an added edge and -1 for a removed one, which is not a weight. */
|
|
350
|
+
const WEIGHTED_TYPES = /* @__PURE__ */ new Set([
|
|
351
|
+
"posweighted",
|
|
352
|
+
"signed",
|
|
353
|
+
"multisigned",
|
|
354
|
+
"weighted",
|
|
355
|
+
"multiweighted",
|
|
356
|
+
"multiposweighted"
|
|
357
|
+
]);
|
|
358
|
+
/** The first line, `% FORMAT WEIGHTS`. */
|
|
359
|
+
const FORMAT_LINE = /^%\s+(\S+)\s+(\S+)\s*$/;
|
|
360
|
+
/** The optional second line, `% EDGES SUBJECTS OBJECTS`: the number of edges and the number of nodes in each of the two sets (equal in a network that is not bipartite). */
|
|
361
|
+
const COUNT_LINE = /^%\s+(\d+)\s+(\d+)\s+(\d+)\s*$/;
|
|
362
|
+
/** A node id: KONECT numbers nodes from 1. */
|
|
363
|
+
const NODE_ID = /^[1-9]\d*$/;
|
|
364
|
+
/** A number as a decimal literal with an optional sign and exponent, so `Number` cannot read hex, an empty string or infinity. */
|
|
365
|
+
const DECIMAL$3 = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
366
|
+
/** The columns of an edge line (handbook section 9.1): the two nodes, then optionally the weight, and the time only when the weight is also given. */
|
|
367
|
+
const ENDPOINT_COLUMNS = 2;
|
|
368
|
+
const WEIGHTED_COLUMNS = 3;
|
|
369
|
+
const TIMESTAMPED_COLUMNS = 4;
|
|
370
|
+
/** The prefix of the node ids in a bipartite network's left and right set, whose numbering each starts from 1. */
|
|
371
|
+
const LEFT_PREFIX = "l:";
|
|
372
|
+
const RIGHT_PREFIX = "r:";
|
|
373
|
+
/**
|
|
374
|
+
* Parses a KONECT TSV file (`out.<network>` in a KONECT download, in the format of section 9.1 of the KONECT Handbook, Kunegis 2017): a first line `% FORMAT WEIGHTS`, an optional second line `% EDGES SUBJECTS OBJECTS`, then one edge per line as two to four whitespace-separated numbers, the source node, the target node, the weight and the time. Other `%` lines are comments.
|
|
375
|
+
*
|
|
376
|
+
* Nodes are the endpoints in order of first appearance, as they are numbered in the file, except in a bipartite network, whose two node sets are numbered independently from 1: there a left node is `l:<n>` and a right node `r:<n>`, so left node 1 and right node 1 are two nodes. Edges are in file order. An edge carries `weight` when the recipe says the file's weights are read, and `timestamp` when it says the file has a time column. The recipe's `weighted` must agree with the file's weight type (`posweighted`, `signed`, `weighted` and their multi-edge forms are weighted; `unweighted` and `positive` are not, and an edge's repetition count in their third column must be 1) and `timestamped` with the file having four columns, so a recipe cannot silently drop a weight or a time the file holds. When the second line is present, its edge count must equal the number of edge lines and every node id must lie within its declared set.
|
|
377
|
+
* @param pieces - The file's text, in order.
|
|
378
|
+
* @param options - The recipe's parser.
|
|
379
|
+
* @returns The native graph.
|
|
380
|
+
* @throws NativeParseError naming the line for a missing or unknown first line, a `dynamic` network, a recipe that disagrees with the file, a malformed edge line, a node id out of its declared range, an edge count that differs from the declared one, and when the file holds no edges.
|
|
381
|
+
*/
|
|
382
|
+
function parseKonect(pieces, options) {
|
|
383
|
+
const order = new NodeOrder();
|
|
384
|
+
const edges = [];
|
|
385
|
+
const state = {};
|
|
386
|
+
let lastLine = 0;
|
|
387
|
+
forEachLine(pieces, (line, number) => {
|
|
388
|
+
lastLine = number;
|
|
389
|
+
const content = line.trim();
|
|
390
|
+
if (content === "") return;
|
|
391
|
+
const { header } = state;
|
|
392
|
+
if (header === void 0) {
|
|
393
|
+
state.header = readHeader(content, number, options);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (content.startsWith("%")) {
|
|
397
|
+
const counts = number === header.line + 1 ? COUNT_LINE.exec(content) : null;
|
|
398
|
+
if (counts?.[1] !== void 0 && counts[2] !== void 0 && counts[3] !== void 0) state.header = {
|
|
399
|
+
...header,
|
|
400
|
+
counts: {
|
|
401
|
+
edges: Number(counts[1]),
|
|
402
|
+
left: Number(counts[2]),
|
|
403
|
+
right: Number(counts[3])
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
edges.push(readEdge(content, number, {
|
|
409
|
+
header,
|
|
410
|
+
options,
|
|
411
|
+
order
|
|
412
|
+
}));
|
|
413
|
+
});
|
|
414
|
+
const { header } = state;
|
|
415
|
+
if (header === void 0) throw new NativeParseError(FORMAT$9, lastLine, "the file has no first line naming its format and weights");
|
|
416
|
+
if (edges.length === 0) throw new NativeParseError(FORMAT$9, lastLine, "the file holds no edges");
|
|
417
|
+
if (header.counts !== void 0 && header.counts.edges !== edges.length) throw new NativeParseError(FORMAT$9, lastLine, `the second line declares ${String(header.counts.edges)} edges, the file holds ${String(edges.length)}`);
|
|
418
|
+
return {
|
|
419
|
+
nodes: order.nodes(),
|
|
420
|
+
edges
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Reads the first line and checks the recipe against it.
|
|
425
|
+
* @param content - The trimmed first non-blank line.
|
|
426
|
+
* @param number - Its 1-based number.
|
|
427
|
+
* @param options - The recipe's parser.
|
|
428
|
+
* @returns The format and weight type it declares.
|
|
429
|
+
* @throws NativeParseError when it is not `% FORMAT WEIGHTS` with a known format, when the network is dynamic, or when the recipe's `weighted` disagrees with the weight type.
|
|
430
|
+
*/
|
|
431
|
+
function readHeader(content, number, options) {
|
|
432
|
+
const match = FORMAT_LINE.exec(content);
|
|
433
|
+
const network = match?.[1];
|
|
434
|
+
const weights = match?.[2];
|
|
435
|
+
if (network === void 0 || weights === void 0 || !NETWORK_FORMATS.has(network)) throw new NativeParseError(FORMAT$9, number, `expected a first line "% FORMAT WEIGHTS" with FORMAT one of ${[...NETWORK_FORMATS].join(", ")}, found ${JSON.stringify(content)}`);
|
|
436
|
+
if (!WEIGHTED_TYPES.has(weights) && !UNWEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$9, 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)`);
|
|
437
|
+
if (options.weighted !== WEIGHTED_TYPES.has(weights)) throw new NativeParseError(FORMAT$9, 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)}`);
|
|
438
|
+
return {
|
|
439
|
+
line: number,
|
|
440
|
+
network
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Reads one edge line.
|
|
445
|
+
* @param content - The trimmed line.
|
|
446
|
+
* @param number - Its 1-based number.
|
|
447
|
+
* @param context - What the first lines declare (`header`), the recipe's parser (`options`) and the `order` that receives the edge's endpoints.
|
|
448
|
+
* @returns The edge.
|
|
449
|
+
* @throws NativeParseError when the line has the wrong number of columns, a node id that is not a positive integer or is outside its declared set, a weight or time that is not a number, or a repetition count other than 1.
|
|
450
|
+
*/
|
|
451
|
+
function readEdge(content, number, context) {
|
|
452
|
+
const { header, options, order } = context;
|
|
453
|
+
const fields = content.split(/\s+/);
|
|
454
|
+
const [from, to, third, time] = fields;
|
|
455
|
+
const expected = options.timestamped ? TIMESTAMPED_COLUMNS : options.weighted ? WEIGHTED_COLUMNS : void 0;
|
|
456
|
+
const fits = expected === void 0 ? fields.length === ENDPOINT_COLUMNS || fields.length === WEIGHTED_COLUMNS : fields.length === expected;
|
|
457
|
+
if (from === void 0 || to === void 0 || !fits) throw new NativeParseError(FORMAT$9, number, `expected ${expected === void 0 ? `${String(ENDPOINT_COLUMNS)} or ${String(WEIGHTED_COLUMNS)}` : String(expected)} columns for this recipe, found ${String(fields.length)}`);
|
|
458
|
+
const bipartite = header.network === "bip";
|
|
459
|
+
const source = endpoint(from, bipartite ? LEFT_PREFIX : "", header.counts?.left, number);
|
|
460
|
+
const target = endpoint(to, bipartite ? RIGHT_PREFIX : "", header.counts?.right, number);
|
|
461
|
+
order.add(source);
|
|
462
|
+
order.add(target);
|
|
463
|
+
if (third !== void 0 && !options.weighted && (!DECIMAL$3.test(third) || Number(third) !== 1)) throw new NativeParseError(FORMAT$9, number, `the third column ${JSON.stringify(third)} is not 1; an edge that stands for several would be read as one`);
|
|
464
|
+
if (third !== void 0 && options.weighted && !DECIMAL$3.test(third)) throw new NativeParseError(FORMAT$9, number, `the weight ${JSON.stringify(third)} is not a number`);
|
|
465
|
+
if (time !== void 0 && !DECIMAL$3.test(time)) throw new NativeParseError(FORMAT$9, number, `the time ${JSON.stringify(time)} is not a number`);
|
|
466
|
+
const edge = {
|
|
467
|
+
source,
|
|
468
|
+
target
|
|
469
|
+
};
|
|
470
|
+
if (options.weighted && third !== void 0) edge.weight = Number(third);
|
|
471
|
+
if (options.timestamped && time !== void 0) edge.timestamp = Number(time);
|
|
472
|
+
return edge;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Reads a node id.
|
|
476
|
+
* @param token - The id as written.
|
|
477
|
+
* @param prefix - The prefix of the node's set in a bipartite network, empty otherwise.
|
|
478
|
+
* @param size - The declared size of the node's set, when the file declares one.
|
|
479
|
+
* @param number - The 1-based line, for the error.
|
|
480
|
+
* @returns The node id.
|
|
481
|
+
* @throws NativeParseError when the token is not a positive integer or exceeds the set's size.
|
|
482
|
+
*/
|
|
483
|
+
function endpoint(token, prefix, size, number) {
|
|
484
|
+
if (!NODE_ID.test(token) || size !== void 0 && Number(token) > size) throw new NativeParseError(FORMAT$9, number, `${JSON.stringify(token)} is not a node id from 1${size === void 0 ? "" : ` to ${String(size)}`}`);
|
|
485
|
+
return `${prefix}${token}`;
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
314
488
|
//#region src/parsers/matrix-market.ts
|
|
315
489
|
/** 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). */
|
|
316
490
|
const tokensPerEntry = /* @__PURE__ */ new Map([
|
|
@@ -885,7 +1059,7 @@ function parsePajek(text) {
|
|
|
885
1059
|
}
|
|
886
1060
|
//#endregion
|
|
887
1061
|
//#region src/parsers/customer-points.ts
|
|
888
|
-
const FORMAT$
|
|
1062
|
+
const FORMAT$8 = "customer-points";
|
|
889
1063
|
const NUMBER$6 = /^-?\d+(?:\.\d+)?$/;
|
|
890
1064
|
const WHOLE$2 = /^\d+$/;
|
|
891
1065
|
/**
|
|
@@ -901,12 +1075,12 @@ function parseCustomerPoints(text) {
|
|
|
901
1075
|
})).filter((row) => row.fields.length > 0);
|
|
902
1076
|
const [first, ...body] = rows;
|
|
903
1077
|
const [key, count, ...extra] = first?.fields ?? [];
|
|
904
|
-
if (first === void 0 || key !== "customers" || count === void 0 || !WHOLE$2.test(count) || extra.length > 0) throw new NativeParseError(FORMAT$
|
|
1078
|
+
if (first === void 0 || key !== "customers" || count === void 0 || !WHOLE$2.test(count) || extra.length > 0) throw new NativeParseError(FORMAT$8, first?.line ?? 1, `expected "customers <count>", found ${JSON.stringify(first?.fields.join(" "))}`);
|
|
905
1079
|
const seen = /* @__PURE__ */ new Set();
|
|
906
1080
|
const nodes = body.map((row) => {
|
|
907
1081
|
const [id, x, y, ...rest] = row.fields;
|
|
908
|
-
if (id === void 0 || x === void 0 || y === void 0 || rest.length > 0 || !WHOLE$2.test(id) || !NUMBER$6.test(x) || !NUMBER$6.test(y)) throw new NativeParseError(FORMAT$
|
|
909
|
-
if (seen.has(id)) throw new NativeParseError(FORMAT$
|
|
1082
|
+
if (id === void 0 || x === void 0 || y === void 0 || rest.length > 0 || !WHOLE$2.test(id) || !NUMBER$6.test(x) || !NUMBER$6.test(y)) throw new NativeParseError(FORMAT$8, row.line, `expected a customer id and two numbers, found ${JSON.stringify(row.fields.join(" "))}`);
|
|
1083
|
+
if (seen.has(id)) throw new NativeParseError(FORMAT$8, row.line, `customer ${id} appears twice`);
|
|
910
1084
|
seen.add(id);
|
|
911
1085
|
return {
|
|
912
1086
|
id,
|
|
@@ -914,7 +1088,7 @@ function parseCustomerPoints(text) {
|
|
|
914
1088
|
y: Number(y)
|
|
915
1089
|
};
|
|
916
1090
|
});
|
|
917
|
-
if (nodes.length !== Number(count)) throw new NativeParseError(FORMAT$
|
|
1091
|
+
if (nodes.length !== Number(count)) throw new NativeParseError(FORMAT$8, rows[rows.length - 1]?.line ?? first.line, `the file announces ${count} customers, found ${String(nodes.length)}`);
|
|
918
1092
|
return {
|
|
919
1093
|
meta: { customers: Number(count) },
|
|
920
1094
|
nodes
|
|
@@ -922,7 +1096,7 @@ function parseCustomerPoints(text) {
|
|
|
922
1096
|
}
|
|
923
1097
|
//#endregion
|
|
924
1098
|
//#region src/parsers/solomon.ts
|
|
925
|
-
const FORMAT$
|
|
1099
|
+
const FORMAT$7 = "solomon";
|
|
926
1100
|
/** A line of integers or decimals separated by any run of spaces and tabs, as the files write rows (Solomon's with padded spaces, the Tenerife set with tabs). */
|
|
927
1101
|
const NUMBER$5 = /^-?\d+(?:\.\d+)?$/;
|
|
928
1102
|
/**
|
|
@@ -934,7 +1108,7 @@ const NUMBER$5 = /^-?\d+(?:\.\d+)?$/;
|
|
|
934
1108
|
* @throws NativeParseError when the token is not a decimal number.
|
|
935
1109
|
*/
|
|
936
1110
|
function numberOf$3(token, line, what) {
|
|
937
|
-
if (!NUMBER$5.test(token)) throw new NativeParseError(FORMAT$
|
|
1111
|
+
if (!NUMBER$5.test(token)) throw new NativeParseError(FORMAT$7, line, `expected ${what}, found ${JSON.stringify(token)}`);
|
|
938
1112
|
return Number(token);
|
|
939
1113
|
}
|
|
940
1114
|
/**
|
|
@@ -947,7 +1121,7 @@ function parseSolomon(text) {
|
|
|
947
1121
|
const all = lines(text);
|
|
948
1122
|
const present = (index) => (all[index] ?? "").trim();
|
|
949
1123
|
const name = present(0);
|
|
950
|
-
if (name === "") throw new NativeParseError(FORMAT$
|
|
1124
|
+
if (name === "") throw new NativeParseError(FORMAT$7, 1, "expected the instance name on the first line");
|
|
951
1125
|
const meta = { name };
|
|
952
1126
|
const nodes = [];
|
|
953
1127
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -963,7 +1137,7 @@ function parseSolomon(text) {
|
|
|
963
1137
|
awaitingHeader = true;
|
|
964
1138
|
continue;
|
|
965
1139
|
}
|
|
966
|
-
if (section === "preamble") throw new NativeParseError(FORMAT$
|
|
1140
|
+
if (section === "preamble") throw new NativeParseError(FORMAT$7, line, `expected VEHICLE or CUSTOMER, found ${JSON.stringify(trimmed)}`);
|
|
967
1141
|
if (awaitingHeader) {
|
|
968
1142
|
awaitingHeader = false;
|
|
969
1143
|
continue;
|
|
@@ -971,15 +1145,15 @@ function parseSolomon(text) {
|
|
|
971
1145
|
const tokens = trimmed.split(/\s+/);
|
|
972
1146
|
if (section === "vehicle") {
|
|
973
1147
|
const [vehicles, capacity, ...extra] = tokens;
|
|
974
|
-
if (vehicles === void 0 || capacity === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$
|
|
1148
|
+
if (vehicles === void 0 || capacity === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$7, line, `expected the vehicle count and capacity, found ${JSON.stringify(trimmed)}`);
|
|
975
1149
|
meta.vehicles = numberOf$3(vehicles, line, "the vehicle count");
|
|
976
1150
|
meta.capacity = numberOf$3(capacity, line, "the vehicle capacity");
|
|
977
1151
|
continue;
|
|
978
1152
|
}
|
|
979
1153
|
const [id, x, y, demand, ready, due, service, ...extra] = tokens;
|
|
980
|
-
if (id === void 0 || x === void 0 || y === void 0 || demand === void 0 || ready === void 0 || due === void 0 || service === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$
|
|
981
|
-
if (!/^\d+$/.test(id)) throw new NativeParseError(FORMAT$
|
|
982
|
-
if (seen.has(id)) throw new NativeParseError(FORMAT$
|
|
1154
|
+
if (id === void 0 || x === void 0 || y === void 0 || demand === void 0 || ready === void 0 || due === void 0 || service === void 0 || extra.length > 0) throw new NativeParseError(FORMAT$7, line, `expected seven numbers (id, x, y, demand, ready, due, service), found ${JSON.stringify(trimmed)}`);
|
|
1155
|
+
if (!/^\d+$/.test(id)) throw new NativeParseError(FORMAT$7, line, `expected a whole number node id, found ${JSON.stringify(id)}`);
|
|
1156
|
+
if (seen.has(id)) throw new NativeParseError(FORMAT$7, line, `node ${id} appears twice`);
|
|
983
1157
|
seen.add(id);
|
|
984
1158
|
nodes.push({
|
|
985
1159
|
id,
|
|
@@ -991,9 +1165,9 @@ function parseSolomon(text) {
|
|
|
991
1165
|
service: numberOf$3(service, line, "the service time")
|
|
992
1166
|
});
|
|
993
1167
|
}
|
|
994
|
-
if (section !== "customer") throw new NativeParseError(FORMAT$
|
|
995
|
-
if (nodes.length === 0) throw new NativeParseError(FORMAT$
|
|
996
|
-
if (meta.vehicles === void 0 || meta.capacity === void 0) throw new NativeParseError(FORMAT$
|
|
1168
|
+
if (section !== "customer") throw new NativeParseError(FORMAT$7, all.length, "expected a CUSTOMER section");
|
|
1169
|
+
if (nodes.length === 0) throw new NativeParseError(FORMAT$7, all.length, "the CUSTOMER section has no rows");
|
|
1170
|
+
if (meta.vehicles === void 0 || meta.capacity === void 0) throw new NativeParseError(FORMAT$7, all.length, "expected a VEHICLE section with the vehicle count and capacity");
|
|
997
1171
|
return {
|
|
998
1172
|
meta: {
|
|
999
1173
|
name: meta.name,
|
|
@@ -1005,7 +1179,7 @@ function parseSolomon(text) {
|
|
|
1005
1179
|
}
|
|
1006
1180
|
//#endregion
|
|
1007
1181
|
//#region src/parsers/top.ts
|
|
1008
|
-
const FORMAT$
|
|
1182
|
+
const FORMAT$6 = "top";
|
|
1009
1183
|
/** The header lines before the first point: `n`, `m` and `tmax`. */
|
|
1010
1184
|
const HEADER_LINES = 3;
|
|
1011
1185
|
const NUMBER$4 = /^-?\d+(?:\.\d+)?$/;
|
|
@@ -1019,7 +1193,7 @@ const NUMBER$4 = /^-?\d+(?:\.\d+)?$/;
|
|
|
1019
1193
|
*/
|
|
1020
1194
|
function headerOf(text, key, line) {
|
|
1021
1195
|
const [name, value, ...extra] = (text ?? "").trim().split(/\s+/);
|
|
1022
|
-
if (name !== key || value === void 0 || extra.length > 0 || !NUMBER$4.test(value)) throw new NativeParseError(FORMAT$
|
|
1196
|
+
if (name !== key || value === void 0 || extra.length > 0 || !NUMBER$4.test(value)) throw new NativeParseError(FORMAT$6, line, `expected "${key} <number>", found ${JSON.stringify((text ?? "").trim())}`);
|
|
1023
1197
|
return Number(value);
|
|
1024
1198
|
}
|
|
1025
1199
|
/**
|
|
@@ -1038,11 +1212,11 @@ function parseTop(text) {
|
|
|
1038
1212
|
const points = headerOf(first?.text, "n", first?.line ?? 1);
|
|
1039
1213
|
const paths = headerOf(second?.text, "m", second?.line ?? 2);
|
|
1040
1214
|
const budget = headerOf(third?.text, "tmax", third?.line ?? HEADER_LINES);
|
|
1041
|
-
if (!Number.isInteger(points) || points < 2) throw new NativeParseError(FORMAT$
|
|
1042
|
-
if (rows.length !== points) throw new NativeParseError(FORMAT$
|
|
1215
|
+
if (!Number.isInteger(points) || points < 2) throw new NativeParseError(FORMAT$6, first?.line ?? 1, `expected at least a start and an end point, found n ${String(points)}`);
|
|
1216
|
+
if (rows.length !== points) throw new NativeParseError(FORMAT$6, all[all.length - 1]?.line ?? 1, `the header gives ${String(points)} points, found ${String(rows.length)} rows`);
|
|
1043
1217
|
const nodes = rows.map((row, index) => {
|
|
1044
1218
|
const [x, y, score, ...extra] = row.text.split(/\s+/);
|
|
1045
|
-
if (x === void 0 || y === void 0 || score === void 0 || extra.length > 0 || !NUMBER$4.test(x) || !NUMBER$4.test(y) || !NUMBER$4.test(score)) throw new NativeParseError(FORMAT$
|
|
1219
|
+
if (x === void 0 || y === void 0 || score === void 0 || extra.length > 0 || !NUMBER$4.test(x) || !NUMBER$4.test(y) || !NUMBER$4.test(score)) throw new NativeParseError(FORMAT$6, row.line, `expected three numbers (x, y, score), found ${JSON.stringify(row.text)}`);
|
|
1046
1220
|
return {
|
|
1047
1221
|
id: String(index),
|
|
1048
1222
|
x: Number(x),
|
|
@@ -1062,7 +1236,7 @@ function parseTop(text) {
|
|
|
1062
1236
|
}
|
|
1063
1237
|
//#endregion
|
|
1064
1238
|
//#region src/parsers/toptw.ts
|
|
1065
|
-
const FORMAT$
|
|
1239
|
+
const FORMAT$5 = "toptw";
|
|
1066
1240
|
/** How many numbers the first header line holds (k, paths, customers, t) and the second (D, capacity). */
|
|
1067
1241
|
const FIRST_HEADER_NUMBERS = 4;
|
|
1068
1242
|
const SECOND_HEADER_NUMBERS = 2;
|
|
@@ -1080,7 +1254,7 @@ const NUMBER$3 = /^-?\d+(?:\.\d+)?$/;
|
|
|
1080
1254
|
* @throws NativeParseError when the token is not a decimal number.
|
|
1081
1255
|
*/
|
|
1082
1256
|
function numberOf$2(token, line, what) {
|
|
1083
|
-
if (token === void 0 || !NUMBER$3.test(token)) throw new NativeParseError(FORMAT$
|
|
1257
|
+
if (token === void 0 || !NUMBER$3.test(token)) throw new NativeParseError(FORMAT$5, line, `expected ${what}, found ${JSON.stringify(token)}`);
|
|
1084
1258
|
return Number(token);
|
|
1085
1259
|
}
|
|
1086
1260
|
/**
|
|
@@ -1094,7 +1268,7 @@ function numberOf$2(token, line, what) {
|
|
|
1094
1268
|
*/
|
|
1095
1269
|
function numbersOf(text, count, line, what) {
|
|
1096
1270
|
const tokens = text.trim().split(/\s+/);
|
|
1097
|
-
if (tokens.length !== count) throw new NativeParseError(FORMAT$
|
|
1271
|
+
if (tokens.length !== count) throw new NativeParseError(FORMAT$5, line, `expected ${what}, found ${JSON.stringify(text.trim())}`);
|
|
1098
1272
|
return tokens.map((token) => numberOf$2(token, line, what));
|
|
1099
1273
|
}
|
|
1100
1274
|
/**
|
|
@@ -1109,18 +1283,18 @@ function parseToptw(text) {
|
|
|
1109
1283
|
text: raw.trim()
|
|
1110
1284
|
})).filter((entry) => entry.text !== "");
|
|
1111
1285
|
const [first, second, ...rows] = all;
|
|
1112
|
-
if (first === void 0 || second === void 0) throw new NativeParseError(FORMAT$
|
|
1286
|
+
if (first === void 0 || second === void 0) throw new NativeParseError(FORMAT$5, 1, "expected the two header lines");
|
|
1113
1287
|
const [, paths, customers] = numbersOf(first.text, FIRST_HEADER_NUMBERS, first.line, "four numbers (k, paths, customers, t)");
|
|
1114
1288
|
const [, capacity] = numbersOf(second.text, SECOND_HEADER_NUMBERS, second.line, "two numbers (D, capacity)");
|
|
1115
|
-
if (paths === void 0 || customers === void 0 || capacity === void 0) throw new NativeParseError(FORMAT$
|
|
1289
|
+
if (paths === void 0 || customers === void 0 || capacity === void 0) throw new NativeParseError(FORMAT$5, first.line, "the header lacks a number");
|
|
1116
1290
|
const seen = /* @__PURE__ */ new Set();
|
|
1117
1291
|
const nodes = rows.map((row) => {
|
|
1118
1292
|
const tokens = row.text.split(/\s+/);
|
|
1119
1293
|
const listLength = numberOf$2(tokens[LIST_LENGTH_AT], row.line, "the length of the list after the unused number");
|
|
1120
|
-
if (!Number.isInteger(listLength) || listLength < 0 || tokens.length !== ROW_NUMBERS_BESIDE_LIST + listLength) throw new NativeParseError(FORMAT$
|
|
1294
|
+
if (!Number.isInteger(listLength) || listLength < 0 || tokens.length !== ROW_NUMBERS_BESIDE_LIST + listLength) throw new NativeParseError(FORMAT$5, row.line, `expected ${String(ROW_NUMBERS_BESIDE_LIST + listLength)} numbers for a list of ${String(listLength)}, found ${String(tokens.length)}`);
|
|
1121
1295
|
const [id] = tokens;
|
|
1122
|
-
if (id === void 0 || !/^\d+$/.test(id)) throw new NativeParseError(FORMAT$
|
|
1123
|
-
if (seen.has(id)) throw new NativeParseError(FORMAT$
|
|
1296
|
+
if (id === void 0 || !/^\d+$/.test(id)) throw new NativeParseError(FORMAT$5, row.line, `expected a whole number node id, found ${JSON.stringify(id)}`);
|
|
1297
|
+
if (seen.has(id)) throw new NativeParseError(FORMAT$5, row.line, `node ${id} appears twice`);
|
|
1124
1298
|
seen.add(id);
|
|
1125
1299
|
return {
|
|
1126
1300
|
id,
|
|
@@ -1132,7 +1306,7 @@ function parseToptw(text) {
|
|
|
1132
1306
|
close: numberOf$2(tokens[8 + listLength], row.line, "the closing of the window")
|
|
1133
1307
|
};
|
|
1134
1308
|
});
|
|
1135
|
-
if (nodes.length !== customers + 1) throw new NativeParseError(FORMAT$
|
|
1309
|
+
if (nodes.length !== customers + 1) throw new NativeParseError(FORMAT$5, all.length === 0 ? 1 : all[all.length - 1]?.line ?? 1, `the header gives ${String(customers)} customers, so expected ${String(customers + 1)} rows with the depot, found ${String(nodes.length)}`);
|
|
1136
1310
|
return {
|
|
1137
1311
|
meta: {
|
|
1138
1312
|
paths,
|
|
@@ -1144,7 +1318,7 @@ function parseToptw(text) {
|
|
|
1144
1318
|
}
|
|
1145
1319
|
//#endregion
|
|
1146
1320
|
//#region src/parsers/tsplib.ts
|
|
1147
|
-
const FORMAT$
|
|
1321
|
+
const FORMAT$4 = "tsplib";
|
|
1148
1322
|
/** The instance types the parser reads: the probabilistic and the clustered orienteering problem. */
|
|
1149
1323
|
const TYPES = /* @__PURE__ */ new Set(["POP", "COP"]);
|
|
1150
1324
|
/** The edge weight types a coordinate section is read under: planar ones keep their coordinates as `x` and `y`, `GEO` converts them to degrees, and `EXPLICIT` has no coordinates. */
|
|
@@ -1175,7 +1349,7 @@ function degrees(value, line) {
|
|
|
1175
1349
|
const whole = Math.trunc(magnitude);
|
|
1176
1350
|
const hundredths = (magnitude - whole) * MINUTE_SCALE;
|
|
1177
1351
|
const minutes = Math.round(hundredths);
|
|
1178
|
-
if (Math.abs(hundredths - minutes) > MINUTE_TOLERANCE || minutes >= MINUTES_PER_DEGREE) throw new NativeParseError(FORMAT$
|
|
1352
|
+
if (Math.abs(hundredths - minutes) > MINUTE_TOLERANCE || minutes >= MINUTES_PER_DEGREE) throw new NativeParseError(FORMAT$4, line, `expected a DDD.MM coordinate with whole minutes below 60, found ${String(value)}`);
|
|
1179
1353
|
return Math.sign(value) * (whole + minutes / MINUTES_PER_DEGREE);
|
|
1180
1354
|
}
|
|
1181
1355
|
/**
|
|
@@ -1187,7 +1361,7 @@ function degrees(value, line) {
|
|
|
1187
1361
|
* @throws NativeParseError when the token is not a decimal number.
|
|
1188
1362
|
*/
|
|
1189
1363
|
function numberOf$1(token, line, what) {
|
|
1190
|
-
if (token === void 0 || !NUMBER$2.test(token)) throw new NativeParseError(FORMAT$
|
|
1364
|
+
if (token === void 0 || !NUMBER$2.test(token)) throw new NativeParseError(FORMAT$4, line, `expected ${what}, found ${JSON.stringify(token)}`);
|
|
1191
1365
|
return Number(token);
|
|
1192
1366
|
}
|
|
1193
1367
|
/**
|
|
@@ -1199,7 +1373,7 @@ function numberOf$1(token, line, what) {
|
|
|
1199
1373
|
* @throws NativeParseError when the token is not a whole number.
|
|
1200
1374
|
*/
|
|
1201
1375
|
function wholeOf(token, line, what) {
|
|
1202
|
-
if (token === void 0 || !WHOLE$1.test(token)) throw new NativeParseError(FORMAT$
|
|
1376
|
+
if (token === void 0 || !WHOLE$1.test(token)) throw new NativeParseError(FORMAT$4, line, `expected ${what}, found ${JSON.stringify(token)}`);
|
|
1203
1377
|
return Number(token);
|
|
1204
1378
|
}
|
|
1205
1379
|
/**
|
|
@@ -1227,7 +1401,7 @@ function collect(text) {
|
|
|
1227
1401
|
continue;
|
|
1228
1402
|
}
|
|
1229
1403
|
if (keyed !== null && key !== void 0) {
|
|
1230
|
-
if (collected.header.has(key)) throw new NativeParseError(FORMAT$
|
|
1404
|
+
if (collected.header.has(key)) throw new NativeParseError(FORMAT$4, line, `${key} appears twice`);
|
|
1231
1405
|
collected.header.set(key, (keyed[2] ?? "").trim());
|
|
1232
1406
|
section = void 0;
|
|
1233
1407
|
continue;
|
|
@@ -1239,9 +1413,9 @@ function collect(text) {
|
|
|
1239
1413
|
const tokens = trimmed.split(/\s+/);
|
|
1240
1414
|
if (section === "coords") {
|
|
1241
1415
|
const [id, x, y, ...extra] = tokens;
|
|
1242
|
-
if (extra.length > 0) throw new NativeParseError(FORMAT$
|
|
1416
|
+
if (extra.length > 0) throw new NativeParseError(FORMAT$4, line, `expected "id x y", found ${JSON.stringify(trimmed)}`);
|
|
1243
1417
|
const node = wholeOf(id, line, "a node id");
|
|
1244
|
-
if (collected.coords.has(node)) throw new NativeParseError(FORMAT$
|
|
1418
|
+
if (collected.coords.has(node)) throw new NativeParseError(FORMAT$4, line, `node ${String(node)} has two coordinate rows`);
|
|
1245
1419
|
collected.coords.set(node, {
|
|
1246
1420
|
x: numberOf$1(x, line, "x"),
|
|
1247
1421
|
y: numberOf$1(y, line, "y"),
|
|
@@ -1249,9 +1423,9 @@ function collect(text) {
|
|
|
1249
1423
|
});
|
|
1250
1424
|
} else if (section === "prizes") {
|
|
1251
1425
|
const [id, prize, probability, ...extra] = tokens;
|
|
1252
|
-
if (extra.length > 0) throw new NativeParseError(FORMAT$
|
|
1426
|
+
if (extra.length > 0) throw new NativeParseError(FORMAT$4, line, `expected "id prize probability", found ${JSON.stringify(trimmed)}`);
|
|
1253
1427
|
const node = wholeOf(id, line, "a node id");
|
|
1254
|
-
if (collected.prizes.has(node)) throw new NativeParseError(FORMAT$
|
|
1428
|
+
if (collected.prizes.has(node)) throw new NativeParseError(FORMAT$4, line, `node ${String(node)} has two prize rows`);
|
|
1255
1429
|
collected.prizes.set(node, {
|
|
1256
1430
|
prize: numberOf$1(prize, line, "the prize"),
|
|
1257
1431
|
probability: numberOf$1(probability, line, "the probability")
|
|
@@ -1259,13 +1433,13 @@ function collect(text) {
|
|
|
1259
1433
|
} else if (section === "sets") {
|
|
1260
1434
|
const [id, prize, ...members] = tokens;
|
|
1261
1435
|
const setId = wholeOf(id, line, "a set id");
|
|
1262
|
-
if (collected.sets.some((set) => set.id === setId)) throw new NativeParseError(FORMAT$
|
|
1436
|
+
if (collected.sets.some((set) => set.id === setId)) throw new NativeParseError(FORMAT$4, line, `set ${String(setId)} appears twice`);
|
|
1263
1437
|
collected.sets.push({
|
|
1264
1438
|
id: setId,
|
|
1265
1439
|
prize: numberOf$1(prize, line, "the set prize"),
|
|
1266
1440
|
members: members.map((member) => wholeOf(member, line, "a node id in a set"))
|
|
1267
1441
|
});
|
|
1268
|
-
} else if (section === void 0) throw new NativeParseError(FORMAT$
|
|
1442
|
+
} else if (section === void 0) throw new NativeParseError(FORMAT$4, line, `expected a KEY : value line or a section, found ${JSON.stringify(trimmed)}`);
|
|
1269
1443
|
}
|
|
1270
1444
|
return collected;
|
|
1271
1445
|
}
|
|
@@ -1278,7 +1452,7 @@ function collect(text) {
|
|
|
1278
1452
|
*/
|
|
1279
1453
|
function required(header, key) {
|
|
1280
1454
|
const value = header.get(key);
|
|
1281
|
-
if (value === void 0) throw new NativeParseError(FORMAT$
|
|
1455
|
+
if (value === void 0) throw new NativeParseError(FORMAT$4, 1, `expected a ${key} line`);
|
|
1282
1456
|
return value;
|
|
1283
1457
|
}
|
|
1284
1458
|
/**
|
|
@@ -1290,23 +1464,23 @@ function required(header, key) {
|
|
|
1290
1464
|
function parseTsplib(text) {
|
|
1291
1465
|
const { header, coords, prizes, sets } = collect(text);
|
|
1292
1466
|
const type = required(header, "TYPE");
|
|
1293
|
-
if (!TYPES.has(type)) throw new NativeParseError(FORMAT$
|
|
1467
|
+
if (!TYPES.has(type)) throw new NativeParseError(FORMAT$4, 1, `expected TYPE POP or COP, found ${JSON.stringify(type)}`);
|
|
1294
1468
|
const weightType = required(header, "EDGE_WEIGHT_TYPE");
|
|
1295
1469
|
const geographic = weightType === "GEO";
|
|
1296
|
-
if (!geographic && !PLANAR.has(weightType) && weightType !== "EXPLICIT") throw new NativeParseError(FORMAT$
|
|
1470
|
+
if (!geographic && !PLANAR.has(weightType) && weightType !== "EXPLICIT") throw new NativeParseError(FORMAT$4, 1, `expected EDGE_WEIGHT_TYPE EUC_2D, CEIL_2D, ATT, GEO or EXPLICIT, found ${JSON.stringify(weightType)}`);
|
|
1297
1471
|
const dimension = wholeOf(required(header, "DIMENSION"), 1, "DIMENSION");
|
|
1298
1472
|
const budget = numberOf$1(required(header, "TMAX"), 1, "TMAX");
|
|
1299
|
-
if (weightType === "EXPLICIT" ? coords.size > 0 : coords.size !== dimension) throw new NativeParseError(FORMAT$
|
|
1473
|
+
if (weightType === "EXPLICIT" ? coords.size > 0 : coords.size !== dimension) throw new NativeParseError(FORMAT$4, 1, weightType === "EXPLICIT" ? "an EXPLICIT file has no NODE_COORD_SECTION" : `expected ${String(dimension)} coordinate rows, found ${String(coords.size)}`);
|
|
1300
1474
|
const inRange = (id) => id >= 1 && id <= dimension;
|
|
1301
1475
|
for (const id of [
|
|
1302
1476
|
...coords.keys(),
|
|
1303
1477
|
...prizes.keys(),
|
|
1304
1478
|
...sets.flatMap((set) => set.members)
|
|
1305
|
-
]) if (!inRange(id)) throw new NativeParseError(FORMAT$
|
|
1306
|
-
if (type === "POP" && prizes.size !== dimension) throw new NativeParseError(FORMAT$
|
|
1479
|
+
]) if (!inRange(id)) throw new NativeParseError(FORMAT$4, 1, `node ${String(id)} is outside 1 to ${String(dimension)}`);
|
|
1480
|
+
if (type === "POP" && prizes.size !== dimension) throw new NativeParseError(FORMAT$4, 1, `expected ${String(dimension)} prize rows, found ${String(prizes.size)}`);
|
|
1307
1481
|
if (type === "COP") {
|
|
1308
1482
|
const declared = wholeOf(required(header, "SETS"), 1, "SETS");
|
|
1309
|
-
if (sets.length !== declared) throw new NativeParseError(FORMAT$
|
|
1483
|
+
if (sets.length !== declared) throw new NativeParseError(FORMAT$4, 1, `expected ${String(declared)} sets, found ${String(sets.length)}`);
|
|
1310
1484
|
}
|
|
1311
1485
|
const clustersOf = (node) => {
|
|
1312
1486
|
const ids = sets.filter((set) => set.members.includes(node)).map((set) => String(set.id));
|
|
@@ -1344,7 +1518,7 @@ function parseTsplib(text) {
|
|
|
1344
1518
|
}
|
|
1345
1519
|
//#endregion
|
|
1346
1520
|
//#region src/parsers/ucinet-dl.ts
|
|
1347
|
-
const FORMAT$
|
|
1521
|
+
const FORMAT$3 = "ucinet-dl";
|
|
1348
1522
|
/** A label section's keyword, upper-cased with its spacing collapsed, mapped to which labels it gives: `LABELS:` names the rows and columns of a one-mode matrix at once, `LEVEL LABELS:` (or `MATRIX LABELS:`) names the matrices. */
|
|
1349
1523
|
const LABEL_SECTIONS = /* @__PURE__ */ new Map([
|
|
1350
1524
|
["LABELS", "both"],
|
|
@@ -1373,7 +1547,7 @@ function labelsOf(text) {
|
|
|
1373
1547
|
* @returns The dimension.
|
|
1374
1548
|
*/
|
|
1375
1549
|
function dimension(value, key, line) {
|
|
1376
|
-
if (!/^[1-9]\d*$/.test(value)) throw new NativeParseError(FORMAT$
|
|
1550
|
+
if (!/^[1-9]\d*$/.test(value)) throw new NativeParseError(FORMAT$3, line, `${key} must be a positive integer, found ${JSON.stringify(value)}`);
|
|
1377
1551
|
return Number(value);
|
|
1378
1552
|
}
|
|
1379
1553
|
/** The dimension parameters, upper-cased, mapped to the header field each sets: N actors of a one-mode matrix, NR rows and NC columns of a two-mode one, NM matrices. */
|
|
@@ -1405,7 +1579,7 @@ function readParameters(text, line) {
|
|
|
1405
1579
|
tokens.shift();
|
|
1406
1580
|
continue;
|
|
1407
1581
|
}
|
|
1408
|
-
throw new NativeParseError(FORMAT$
|
|
1582
|
+
throw new NativeParseError(FORMAT$3, line, `unknown header token ${JSON.stringify(token)}`);
|
|
1409
1583
|
}
|
|
1410
1584
|
const key = upper.slice(0, equals);
|
|
1411
1585
|
const value = token.slice(equals + 1);
|
|
@@ -1420,7 +1594,7 @@ function readParameters(text, line) {
|
|
|
1420
1594
|
kind: "format",
|
|
1421
1595
|
value: value.toUpperCase()
|
|
1422
1596
|
});
|
|
1423
|
-
else throw new NativeParseError(FORMAT$
|
|
1597
|
+
else throw new NativeParseError(FORMAT$3, line, `unknown header parameter ${key}`);
|
|
1424
1598
|
}
|
|
1425
1599
|
return parameters;
|
|
1426
1600
|
}
|
|
@@ -1433,10 +1607,10 @@ function readParameters(text, line) {
|
|
|
1433
1607
|
*/
|
|
1434
1608
|
function checked(labels, count, what) {
|
|
1435
1609
|
if (labels === void 0) return;
|
|
1436
|
-
if (labels.values.length !== count) throw new NativeParseError(FORMAT$
|
|
1610
|
+
if (labels.values.length !== count) throw new NativeParseError(FORMAT$3, labels.line, `expected ${String(count)} ${what} labels, found ${String(labels.values.length)}`);
|
|
1437
1611
|
const seen = /* @__PURE__ */ new Set();
|
|
1438
1612
|
for (const label of labels.values) {
|
|
1439
|
-
if (seen.has(label)) throw new NativeParseError(FORMAT$
|
|
1613
|
+
if (seen.has(label)) throw new NativeParseError(FORMAT$3, labels.line, `${what} label ${JSON.stringify(label)} appears twice`);
|
|
1440
1614
|
seen.add(label);
|
|
1441
1615
|
}
|
|
1442
1616
|
return labels.values;
|
|
@@ -1455,7 +1629,7 @@ function parseUcinetDl(text, options) {
|
|
|
1455
1629
|
const header = { labels: {} };
|
|
1456
1630
|
const setParameters = (parameterText, line) => {
|
|
1457
1631
|
for (const parameter of readParameters(parameterText, line)) if (parameter.kind === "dimension") {
|
|
1458
|
-
if (header[parameter.field] !== void 0) throw new NativeParseError(FORMAT$
|
|
1632
|
+
if (header[parameter.field] !== void 0) throw new NativeParseError(FORMAT$3, line, `${parameter.key} is declared twice`);
|
|
1459
1633
|
header[parameter.field] = parameter.value;
|
|
1460
1634
|
} else if (parameter.kind === "format") header.format = parameter.value;
|
|
1461
1635
|
else header.diagonal = parameter.value;
|
|
@@ -1477,7 +1651,7 @@ function parseUcinetDl(text, options) {
|
|
|
1477
1651
|
if (content === "") continue;
|
|
1478
1652
|
if (!started) {
|
|
1479
1653
|
const match = /^DL(?:\s+(.*))?$/i.exec(content);
|
|
1480
|
-
if (match === null) throw new NativeParseError(FORMAT$
|
|
1654
|
+
if (match === null) throw new NativeParseError(FORMAT$3, lineNumber, "a DL file starts with the keyword DL");
|
|
1481
1655
|
started = true;
|
|
1482
1656
|
if (match[1] !== void 0) setParameters(match[1], lineNumber);
|
|
1483
1657
|
continue;
|
|
@@ -1495,7 +1669,7 @@ function parseUcinetDl(text, options) {
|
|
|
1495
1669
|
}
|
|
1496
1670
|
const target = keyword === void 0 ? void 0 : LABEL_SECTIONS.get(keyword);
|
|
1497
1671
|
if (target !== void 0) {
|
|
1498
|
-
if (header.labels[target] !== void 0) throw new NativeParseError(FORMAT$
|
|
1672
|
+
if (header.labels[target] !== void 0) throw new NativeParseError(FORMAT$3, lineNumber, `${String(keyword)} is given twice`);
|
|
1499
1673
|
section = target;
|
|
1500
1674
|
header.labels[target] = {
|
|
1501
1675
|
values: labelsOf(rest),
|
|
@@ -1503,31 +1677,31 @@ function parseUcinetDl(text, options) {
|
|
|
1503
1677
|
};
|
|
1504
1678
|
continue;
|
|
1505
1679
|
}
|
|
1506
|
-
if (/^LABELS\s+EMBEDDED\b/i.test(content)) throw new NativeParseError(FORMAT$
|
|
1680
|
+
if (/^LABELS\s+EMBEDDED\b/i.test(content)) throw new NativeParseError(FORMAT$3, lineNumber, "labels embedded in the data are not read in this version");
|
|
1507
1681
|
if (section === void 0) setParameters(content, lineNumber);
|
|
1508
1682
|
else header.labels[section]?.values.push(...labelsOf(content));
|
|
1509
1683
|
}
|
|
1510
|
-
if (!started) throw new NativeParseError(FORMAT$
|
|
1511
|
-
if (dataLine === void 0) throw new NativeParseError(FORMAT$
|
|
1684
|
+
if (!started) throw new NativeParseError(FORMAT$3, 1, "the file is empty");
|
|
1685
|
+
if (dataLine === void 0) throw new NativeParseError(FORMAT$3, all.length, "the file has no DATA: section");
|
|
1512
1686
|
const format = header.format ?? "FULLMATRIX";
|
|
1513
|
-
if (!FULL_MATRIX.has(format)) throw new NativeParseError(FORMAT$
|
|
1514
|
-
if (header.diagonal === "ABSENT") throw new NativeParseError(FORMAT$
|
|
1687
|
+
if (!FULL_MATRIX.has(format)) throw new NativeParseError(FORMAT$3, dataLine, `format ${format} is not read in this version; only the full matrix layout is`);
|
|
1688
|
+
if (header.diagonal === "ABSENT") throw new NativeParseError(FORMAT$3, dataLine, "a full matrix with its diagonal absent is not read in this version");
|
|
1515
1689
|
const twoMode = header.nr !== void 0 || header.nc !== void 0;
|
|
1516
|
-
if (twoMode === (header.n !== void 0) || twoMode && (header.nr === void 0 || header.nc === void 0)) throw new NativeParseError(FORMAT$
|
|
1690
|
+
if (twoMode === (header.n !== void 0) || twoMode && (header.nr === void 0 || header.nc === void 0)) throw new NativeParseError(FORMAT$3, dataLine, "the header must give either N, or both NR and NC");
|
|
1517
1691
|
const rows = header.n ?? header.nr ?? 0;
|
|
1518
1692
|
const columns = header.n ?? header.nc ?? 0;
|
|
1519
1693
|
const matrices = header.nm ?? 1;
|
|
1520
1694
|
checked(header.labels.matrices, matrices, "matrix");
|
|
1521
|
-
if (options.matrix > matrices) throw new NativeParseError(FORMAT$
|
|
1695
|
+
if (options.matrix > matrices) throw new NativeParseError(FORMAT$3, dataLine, `the recipe reads matrix ${String(options.matrix)} but the file holds ${String(matrices)}`);
|
|
1522
1696
|
let rowIds;
|
|
1523
1697
|
let columnIds;
|
|
1524
1698
|
if (twoMode) {
|
|
1525
|
-
if (header.labels.both !== void 0) throw new NativeParseError(FORMAT$
|
|
1699
|
+
if (header.labels.both !== void 0) throw new NativeParseError(FORMAT$3, header.labels.both.line, "a two-mode file names its rows and columns with ROW LABELS: and COLUMN LABELS:, not LABELS:");
|
|
1526
1700
|
rowIds = checked(header.labels.rows, rows, "row") ?? Array.from({ length: rows }, (_, row) => `r${String(row + 1)}`);
|
|
1527
1701
|
columnIds = checked(header.labels.columns, columns, "column") ?? Array.from({ length: columns }, (_, column) => `c${String(column + 1)}`);
|
|
1528
1702
|
const rowSet = new Set(rowIds);
|
|
1529
1703
|
const shared = columnIds.find((id) => rowSet.has(id));
|
|
1530
|
-
if (shared !== void 0) throw new NativeParseError(FORMAT$
|
|
1704
|
+
if (shared !== void 0) throw new NativeParseError(FORMAT$3, dataLine, `the row and column label ${JSON.stringify(shared)} names two different entities`);
|
|
1531
1705
|
} else {
|
|
1532
1706
|
const given = [
|
|
1533
1707
|
checked(header.labels.both, rows, "actor"),
|
|
@@ -1535,18 +1709,18 @@ function parseUcinetDl(text, options) {
|
|
|
1535
1709
|
checked(header.labels.columns, columns, "column")
|
|
1536
1710
|
].filter((labels) => labels !== void 0);
|
|
1537
1711
|
const [first] = given;
|
|
1538
|
-
if (first !== void 0 && given.some((labels) => labels.some((label, position) => label !== first[position]))) throw new NativeParseError(FORMAT$
|
|
1712
|
+
if (first !== void 0 && given.some((labels) => labels.some((label, position) => label !== first[position]))) throw new NativeParseError(FORMAT$3, dataLine, "a one-mode file's row and column labels must be the same actors in the same order");
|
|
1539
1713
|
rowIds = first ?? Array.from({ length: rows }, (_, row) => String(row + 1));
|
|
1540
1714
|
columnIds = rowIds;
|
|
1541
1715
|
}
|
|
1542
1716
|
const perMatrix = rows * columns;
|
|
1543
|
-
if (cells.length !== perMatrix * matrices) throw new NativeParseError(FORMAT$
|
|
1544
|
-
for (const cell of cells) if (!NUMBER$1.test(cell.text)) throw new NativeParseError(FORMAT$
|
|
1717
|
+
if (cells.length !== perMatrix * matrices) throw new NativeParseError(FORMAT$3, cells.at(-1)?.line ?? dataLine, `expected ${String(matrices)} full ${String(rows)} by ${String(columns)} matrices (${String(perMatrix * matrices)} cells), found ${String(cells.length)} cells`);
|
|
1718
|
+
for (const cell of cells) if (!NUMBER$1.test(cell.text)) throw new NativeParseError(FORMAT$3, cell.line, `cell ${JSON.stringify(cell.text)} is not a number`);
|
|
1545
1719
|
const chosen = cells.slice((options.matrix - 1) * perMatrix, options.matrix * perMatrix);
|
|
1546
1720
|
const edges = [];
|
|
1547
1721
|
for (const [row, source] of rowIds.entries()) for (const [column, target] of columnIds.entries()) {
|
|
1548
1722
|
const cell = chosen[row * columns + column];
|
|
1549
|
-
if (cell === void 0) throw new NativeParseError(FORMAT$
|
|
1723
|
+
if (cell === void 0) throw new NativeParseError(FORMAT$3, dataLine, `matrix ${String(options.matrix)} has no cell at row ${String(row + 1)}, column ${String(column + 1)}`);
|
|
1550
1724
|
if (Number(cell.text) !== 0) edges.push({
|
|
1551
1725
|
source,
|
|
1552
1726
|
target
|
|
@@ -1559,7 +1733,7 @@ function parseUcinetDl(text, options) {
|
|
|
1559
1733
|
}
|
|
1560
1734
|
//#endregion
|
|
1561
1735
|
//#region src/parsers/verolog-2019.ts
|
|
1562
|
-
const FORMAT$
|
|
1736
|
+
const FORMAT$2 = "verolog-2019";
|
|
1563
1737
|
/** The header lines, in the order the file has them, with the key of `meta` each fills and whether its value is text. */
|
|
1564
1738
|
const HEADER$1 = [
|
|
1565
1739
|
[
|
|
@@ -1660,7 +1834,7 @@ function rowsOf(text) {
|
|
|
1660
1834
|
*/
|
|
1661
1835
|
function numberAt(row, index, what) {
|
|
1662
1836
|
const field = row.fields[index];
|
|
1663
|
-
if (field === void 0 || !NUMBER.test(field)) throw new NativeParseError(FORMAT$
|
|
1837
|
+
if (field === void 0 || !NUMBER.test(field)) throw new NativeParseError(FORMAT$2, row.line, `expected ${what}, found ${JSON.stringify(field)}`);
|
|
1664
1838
|
return Number(field);
|
|
1665
1839
|
}
|
|
1666
1840
|
/**
|
|
@@ -1684,8 +1858,8 @@ function keyed(row) {
|
|
|
1684
1858
|
*/
|
|
1685
1859
|
function checkRows(name, rows, length) {
|
|
1686
1860
|
for (const [index, row] of rows.entries()) {
|
|
1687
|
-
if (row.fields[0] !== String(index + 1)) throw new NativeParseError(FORMAT$
|
|
1688
|
-
if (length !== void 0 && row.fields.length !== length) throw new NativeParseError(FORMAT$
|
|
1861
|
+
if (row.fields[0] !== String(index + 1)) throw new NativeParseError(FORMAT$2, row.line, `expected ${name} row ${String(index + 1)} to start with that id, found ${JSON.stringify(row.fields[0])}`);
|
|
1862
|
+
if (length !== void 0 && row.fields.length !== length) throw new NativeParseError(FORMAT$2, row.line, `expected ${String(length)} fields in a ${name} row, found ${String(row.fields.length)}`);
|
|
1689
1863
|
}
|
|
1690
1864
|
}
|
|
1691
1865
|
/**
|
|
@@ -1700,15 +1874,15 @@ function sectionsOf(rows) {
|
|
|
1700
1874
|
for (const name of SECTIONS) {
|
|
1701
1875
|
const start = rows[at];
|
|
1702
1876
|
const announced = start === void 0 ? void 0 : keyed(start);
|
|
1703
|
-
if (start === void 0 || announced?.key !== name || announced.value.length !== 1 || !WHOLE.test(announced.value[0] ?? "")) throw new NativeParseError(FORMAT$
|
|
1877
|
+
if (start === void 0 || announced?.key !== name || announced.value.length !== 1 || !WHOLE.test(announced.value[0] ?? "")) throw new NativeParseError(FORMAT$2, start?.line ?? 1, `expected "${name} = <count>", found ${JSON.stringify(start?.fields.join(" "))}`);
|
|
1704
1878
|
const count = Number(announced.value[0]);
|
|
1705
1879
|
const body = rows.slice(at + 1, at + 1 + count);
|
|
1706
|
-
if (body.length !== count) throw new NativeParseError(FORMAT$
|
|
1880
|
+
if (body.length !== count) throw new NativeParseError(FORMAT$2, start.line, `${name} announces ${String(count)} rows, found ${String(body.length)}`);
|
|
1707
1881
|
found[name] = body;
|
|
1708
1882
|
at += 1 + count;
|
|
1709
1883
|
}
|
|
1710
1884
|
const extra = rows[at];
|
|
1711
|
-
if (extra !== void 0) throw new NativeParseError(FORMAT$
|
|
1885
|
+
if (extra !== void 0) throw new NativeParseError(FORMAT$2, extra.line, `expected the end of the file after TECHNICIANS, found ${JSON.stringify(extra.fields.join(" "))}`);
|
|
1712
1886
|
return {
|
|
1713
1887
|
MACHINES: found.MACHINES ?? [],
|
|
1714
1888
|
LOCATIONS: found.LOCATIONS ?? [],
|
|
@@ -1729,7 +1903,7 @@ function parseVerolog2019(text) {
|
|
|
1729
1903
|
for (const [position, [key, name, kind]] of HEADER$1.entries()) {
|
|
1730
1904
|
const row = rows[position];
|
|
1731
1905
|
const header = row === void 0 ? void 0 : keyed(row);
|
|
1732
|
-
if (row === void 0 || header?.key !== key || header.value.length === 0) throw new NativeParseError(FORMAT$
|
|
1906
|
+
if (row === void 0 || header?.key !== key || header.value.length === 0) throw new NativeParseError(FORMAT$2, row?.line ?? 1, `expected "${key} = <value>", found ${JSON.stringify(row?.fields.join(" "))}`);
|
|
1733
1907
|
meta[name] = kind === "text" ? header.value.join(" ") : numberAt({
|
|
1734
1908
|
line: row.line,
|
|
1735
1909
|
fields: header.value
|
|
@@ -1769,10 +1943,10 @@ function parseVerolog2019(text) {
|
|
|
1769
1943
|
const toDay = numberAt(row, REQUEST_AT.toDay, "the last day");
|
|
1770
1944
|
const machine = numberAt(row, REQUEST_AT.machine, "a machine id");
|
|
1771
1945
|
const amount = numberAt(row, REQUEST_AT.amount, "the amount");
|
|
1772
|
-
if (!Number.isInteger(location) || location < 1 || location > locations.length) throw new NativeParseError(FORMAT$
|
|
1773
|
-
if (!Number.isInteger(machine) || machine < 1 || machine > machines.length) throw new NativeParseError(FORMAT$
|
|
1774
|
-
if (fromDay < 1 || fromDay > toDay || toDay > days) throw new NativeParseError(FORMAT$
|
|
1775
|
-
if (amount < 1) throw new NativeParseError(FORMAT$
|
|
1946
|
+
if (!Number.isInteger(location) || location < 1 || location > locations.length) throw new NativeParseError(FORMAT$2, row.line, `location ${String(location)} is outside 1 to ${String(locations.length)}`);
|
|
1947
|
+
if (!Number.isInteger(machine) || machine < 1 || machine > machines.length) throw new NativeParseError(FORMAT$2, row.line, `machine ${String(machine)} is outside 1 to ${String(machines.length)}`);
|
|
1948
|
+
if (fromDay < 1 || fromDay > toDay || toDay > days) throw new NativeParseError(FORMAT$2, row.line, `the days ${String(fromDay)} to ${String(toDay)} are not within 1 to ${String(days)}`);
|
|
1949
|
+
if (amount < 1) throw new NativeParseError(FORMAT$2, row.line, `expected an amount of at least 1, found ${String(amount)}`);
|
|
1776
1950
|
return {
|
|
1777
1951
|
id: row.fields[0] ?? "",
|
|
1778
1952
|
...position(location),
|
|
@@ -1793,6 +1967,98 @@ function parseVerolog2019(text) {
|
|
|
1793
1967
|
};
|
|
1794
1968
|
}
|
|
1795
1969
|
//#endregion
|
|
1970
|
+
//#region src/parsers/user-item-ratings.ts
|
|
1971
|
+
const FORMAT$1 = "user-item-ratings";
|
|
1972
|
+
/** The text each delimiter option separates a line's fields with. */
|
|
1973
|
+
const SEPARATORS = {
|
|
1974
|
+
comma: ",",
|
|
1975
|
+
tab: " ",
|
|
1976
|
+
"double-colon": "::"
|
|
1977
|
+
};
|
|
1978
|
+
/** The prefix an id takes from the set it is in, so that user 1 and movie 1, which a source numbers independently, are two nodes: `u:` for a user and the first letter of the item type and a colon for an item. */
|
|
1979
|
+
const USER_PREFIX = "u:";
|
|
1980
|
+
const ITEM_PREFIXES = {
|
|
1981
|
+
movie: "m:",
|
|
1982
|
+
product: "p:"
|
|
1983
|
+
};
|
|
1984
|
+
/** The node type of a user, and the edge type of a rating. */
|
|
1985
|
+
const USER_TYPE = "user";
|
|
1986
|
+
const RATING_TYPE = "rating";
|
|
1987
|
+
/** A rating as a decimal literal: digits with an optional fraction, no sign, so `Number` cannot read it as hex, as an empty string or as infinity. */
|
|
1988
|
+
const RATING = /^\d+(?:\.\d+)?$/;
|
|
1989
|
+
/** A time as a count of whole units since the epoch, in digits only. */
|
|
1990
|
+
const TIMESTAMP = /^\d+$/;
|
|
1991
|
+
/** The fields on a rating's line: the user, the item, the rating and when it was given. */
|
|
1992
|
+
const FIELDS = 4;
|
|
1993
|
+
/**
|
|
1994
|
+
* Parses a file of ratings, one per line as `user`, `item`, `rating` and `timestamp` separated by the recipe's delimiter, after `skipLines` header lines, into a bipartite graph. This is the layout of an Amazon ratings-only file (comma, no header) and of each MovieLens release (tab for `u.data`, `::` for `ratings.dat`, comma with a header line for `ratings.csv`).
|
|
1995
|
+
*
|
|
1996
|
+
* Nodes are the users and the items in order of first appearance, a user as `u:<id>` of type `user` and an item as `m:<id>` or `p:<id>` of the recipe's item type. Each line is one edge from its user to its item, in file order, of type `rating`, with `weight` the rating over the recipe's `maxRating` (so a weight is in (0, 1]) and `timestamp` the time field as the file writes it. Blank lines are skipped. A user who rates an item twice gives two edges.
|
|
1997
|
+
*
|
|
1998
|
+
* The file is read a piece of text at a time and each distinct id is held once, however many edges name it.
|
|
1999
|
+
* @param pieces - The file's text, in order.
|
|
2000
|
+
* @param options - The recipe's parser.
|
|
2001
|
+
* @returns The native graph.
|
|
2002
|
+
* @throws NativeParseError naming the line for a line without exactly four fields, an empty id, a rating that is not a positive number or exceeds `maxRating`, or a timestamp that is not a whole number, and when the file holds no ratings.
|
|
2003
|
+
*/
|
|
2004
|
+
function parseUserItemRatings(pieces, options) {
|
|
2005
|
+
const separator = SEPARATORS[options.delimiter];
|
|
2006
|
+
const itemPrefix = ITEM_PREFIXES[options.itemType];
|
|
2007
|
+
const users = /* @__PURE__ */ new Map();
|
|
2008
|
+
const items = /* @__PURE__ */ new Map();
|
|
2009
|
+
const nodes = [];
|
|
2010
|
+
const edges = [];
|
|
2011
|
+
/** The ids of one node set seen so far, by source id, so each distinct id is held once however many edges name it. */
|
|
2012
|
+
const sets = {
|
|
2013
|
+
user: {
|
|
2014
|
+
known: users,
|
|
2015
|
+
prefix: USER_PREFIX,
|
|
2016
|
+
type: USER_TYPE
|
|
2017
|
+
},
|
|
2018
|
+
item: {
|
|
2019
|
+
known: items,
|
|
2020
|
+
prefix: itemPrefix,
|
|
2021
|
+
type: options.itemType
|
|
2022
|
+
}
|
|
2023
|
+
};
|
|
2024
|
+
/** Returns the node id of a source id in a set, adding the node when the id has not appeared in it. */
|
|
2025
|
+
const idOf = (set, raw, number) => {
|
|
2026
|
+
if (raw === "") throw new NativeParseError(FORMAT$1, number, `the ${set.type} id is empty`);
|
|
2027
|
+
let id = set.known.get(raw);
|
|
2028
|
+
if (id === void 0) {
|
|
2029
|
+
id = `${set.prefix}${raw}`;
|
|
2030
|
+
set.known.set(raw, id);
|
|
2031
|
+
nodes.push({
|
|
2032
|
+
id,
|
|
2033
|
+
type: set.type
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
return id;
|
|
2037
|
+
};
|
|
2038
|
+
let lastLine = 0;
|
|
2039
|
+
forEachLine(pieces, (line, number) => {
|
|
2040
|
+
lastLine = number;
|
|
2041
|
+
if (number <= options.skipLines || line.trim() === "") return;
|
|
2042
|
+
const fields = line.split(separator);
|
|
2043
|
+
const [user, item, rating, timestamp] = fields;
|
|
2044
|
+
if (fields.length !== FIELDS || user === void 0 || item === void 0 || rating === void 0 || timestamp === void 0) throw new NativeParseError(FORMAT$1, number, `expected ${String(FIELDS)} fields (user, item, rating, timestamp), found ${String(fields.length)}`);
|
|
2045
|
+
if (!RATING.test(rating) || Number(rating) <= 0 || Number(rating) > options.maxRating) throw new NativeParseError(FORMAT$1, number, `the rating ${JSON.stringify(rating)} is not a number above 0 and at most ${String(options.maxRating)}`);
|
|
2046
|
+
if (!TIMESTAMP.test(timestamp) || !Number.isSafeInteger(Number(timestamp))) throw new NativeParseError(FORMAT$1, number, `the timestamp ${JSON.stringify(timestamp)} is not a whole number`);
|
|
2047
|
+
edges.push({
|
|
2048
|
+
source: idOf(sets.user, user, number),
|
|
2049
|
+
target: idOf(sets.item, item, number),
|
|
2050
|
+
type: RATING_TYPE,
|
|
2051
|
+
weight: Number(rating) / options.maxRating,
|
|
2052
|
+
timestamp: Number(timestamp)
|
|
2053
|
+
});
|
|
2054
|
+
});
|
|
2055
|
+
if (edges.length === 0) throw new NativeParseError(FORMAT$1, lastLine, "the file holds no ratings");
|
|
2056
|
+
return {
|
|
2057
|
+
nodes,
|
|
2058
|
+
edges
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
//#endregion
|
|
1796
2062
|
//#region src/parsers/xml.ts
|
|
1797
2063
|
/** The characters an XML name may contain, narrowed to what the formats read use. */
|
|
1798
2064
|
const NAME = /^[A-Za-z_][A-Za-z0-9_.:-]*$/;
|
|
@@ -2151,6 +2417,8 @@ function* decodePieces(bytes, parser, pieceBytes = DECODE_PIECE_BYTES) {
|
|
|
2151
2417
|
}
|
|
2152
2418
|
function parseNative(bytes, parser) {
|
|
2153
2419
|
if (parser.format === "csv-table") return parseCsvTable(decodePieces(bytes, parser), parser);
|
|
2420
|
+
if (parser.format === "user-item-ratings") return parseUserItemRatings(decodePieces(bytes, parser), parser);
|
|
2421
|
+
if (parser.format === "konect") return parseKonect(decodePieces(bytes, parser), parser);
|
|
2154
2422
|
const text = decode(bytes, parser);
|
|
2155
2423
|
if (parser.format === "geojson") return parseGeojson(text, parser);
|
|
2156
2424
|
if (parser.format === "edge-list") return parseEdgeList(text, parser);
|
|
@@ -2167,6 +2435,48 @@ function parseNative(bytes, parser) {
|
|
|
2167
2435
|
if (parser.format === "vrp-rep") return parseVrpRep(text);
|
|
2168
2436
|
return parsePajek(text);
|
|
2169
2437
|
}
|
|
2438
|
+
//#endregion
|
|
2439
|
+
//#region src/node/bzip2.ts
|
|
2440
|
+
/** The four bytes every bzip2 stream starts with, as ASCII: `BZh` and the block size digit, `1` to `9`. */
|
|
2441
|
+
const MAGIC = /^BZh[1-9]$/;
|
|
2442
|
+
const MAGIC_LENGTH = 4;
|
|
2443
|
+
/**
|
|
2444
|
+
* Whether the bytes start a bzip2 stream.
|
|
2445
|
+
* @param bytes - The data.
|
|
2446
|
+
* @returns True when they begin with `BZh` and a block size digit.
|
|
2447
|
+
*/
|
|
2448
|
+
function startsStream(bytes) {
|
|
2449
|
+
return MAGIC.test(String.fromCharCode(...bytes.subarray(0, MAGIC_LENGTH)));
|
|
2450
|
+
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Decompresses bzip2 data: one stream or several concatenated ones, as `bzip2 -d` reads them. The decoder verifies every block's CRC and the stream's, and fails on input that ends inside a stream or holds anything after the last one, so a download cut short or corrupted never yields a prefix of the file.
|
|
2453
|
+
*
|
|
2454
|
+
* `unbzip2-stream` is a stream interface, but its decoder runs inside `write` and `end` and emits every `data`, `error` and `end` event before they return, so one write of the whole input and an `end` give the whole output synchronously; the check that `end` was emitted guards that.
|
|
2455
|
+
* @param bytes - The compressed data.
|
|
2456
|
+
* @returns The decompressed bytes.
|
|
2457
|
+
* @throws ArchiveError when the data does not start with a bzip2 header, fails a CRC, ends early or holds trailing bytes that are not another stream.
|
|
2458
|
+
*/
|
|
2459
|
+
function bunzip2(bytes) {
|
|
2460
|
+
if (!startsStream(bytes)) throw new ArchiveError("not valid bzip2 data: it does not start with the BZh header and a block size digit");
|
|
2461
|
+
const decoder = unbzip2();
|
|
2462
|
+
const chunks = [];
|
|
2463
|
+
const outcome = { finished: false };
|
|
2464
|
+
decoder.on("data", (chunk) => {
|
|
2465
|
+
chunks.push(chunk);
|
|
2466
|
+
});
|
|
2467
|
+
decoder.on("error", (error) => {
|
|
2468
|
+
outcome.failure ??= error;
|
|
2469
|
+
});
|
|
2470
|
+
decoder.on("end", () => {
|
|
2471
|
+
outcome.finished = true;
|
|
2472
|
+
});
|
|
2473
|
+
decoder.write(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
|
|
2474
|
+
decoder.end();
|
|
2475
|
+
if (outcome.failure !== void 0) throw new ArchiveError(`not valid bzip2 data: ${outcome.failure instanceof Error ? outcome.failure.message : JSON.stringify(outcome.failure)}`, { cause: outcome.failure });
|
|
2476
|
+
if (!outcome.finished) throw new ArchiveError("the bzip2 decoder did not finish when its input ended");
|
|
2477
|
+
const joined = Buffer.concat(chunks);
|
|
2478
|
+
return new Uint8Array(joined.buffer, joined.byteOffset, joined.byteLength);
|
|
2479
|
+
}
|
|
2170
2480
|
/** The ustar header layout (POSIX.1-1988, pax(1) "ustar Interchange Format"): each field's byte offset and length. */
|
|
2171
2481
|
const HEADER = {
|
|
2172
2482
|
name: {
|
|
@@ -2608,6 +2918,7 @@ function pick(paths, member) {
|
|
|
2608
2918
|
function unpack(bytes, archive) {
|
|
2609
2919
|
if (archive.kind === "none") return { bytes };
|
|
2610
2920
|
if (archive.kind === "gzip") return { bytes: gunzip(bytes) };
|
|
2921
|
+
if (archive.kind === "bzip2") return { bytes: bunzip2(bytes) };
|
|
2611
2922
|
if (archive.kind === "xlsx") return {
|
|
2612
2923
|
bytes: sheetToCsv(bytes, archive.sheet),
|
|
2613
2924
|
member: archive.sheet
|
|
@@ -2646,7 +2957,7 @@ function selectMember(bytes, archive) {
|
|
|
2646
2957
|
member: path
|
|
2647
2958
|
};
|
|
2648
2959
|
}
|
|
2649
|
-
const members = readTar(archive.kind
|
|
2960
|
+
const members = readTar(untarred(bytes, archive.kind));
|
|
2650
2961
|
const path = pick(members.map((member) => member.path), archive.member);
|
|
2651
2962
|
const found = members.find((member) => member.path === path);
|
|
2652
2963
|
if (found === void 0) throw new ArchiveError(`tar member ${path} could not be read`);
|
|
@@ -2681,7 +2992,7 @@ function unpackMembers(bytes, archive) {
|
|
|
2681
2992
|
};
|
|
2682
2993
|
});
|
|
2683
2994
|
}
|
|
2684
|
-
const members = readTar(archive.kind
|
|
2995
|
+
const members = readTar(untarred(bytes, archive.kind));
|
|
2685
2996
|
return pickAll(members.map((member) => member.path), archive.members).map((path) => {
|
|
2686
2997
|
const found = members.find((member) => member.path === path);
|
|
2687
2998
|
if (found === void 0) throw new ArchiveError(`tar member ${path} could not be read`);
|
|
@@ -2692,6 +3003,16 @@ function unpackMembers(bytes, archive) {
|
|
|
2692
3003
|
});
|
|
2693
3004
|
}
|
|
2694
3005
|
/**
|
|
3006
|
+
* Removes the compression a tar archive kind states, leaving the tar bytes.
|
|
3007
|
+
* @param bytes - The downloaded bytes.
|
|
3008
|
+
* @param kind - The tar archive kind: `tar` is uncompressed, `tar.gz` is gzip and `tar.bz2` is bzip2.
|
|
3009
|
+
* @returns The tar archive.
|
|
3010
|
+
*/
|
|
3011
|
+
function untarred(bytes, kind) {
|
|
3012
|
+
if (kind === "tar") return bytes;
|
|
3013
|
+
return kind === "tar.gz" ? gunzip(bytes) : bunzip2(bytes);
|
|
3014
|
+
}
|
|
3015
|
+
/**
|
|
2695
3016
|
* Gunzips bytes, rethrowing zlib's failure in the package's taxonomy.
|
|
2696
3017
|
* @param bytes - Gzip data.
|
|
2697
3018
|
* @returns The decompressed bytes.
|