@jbrowse/graph-walks 0.0.0-stage → 0.1.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.
@@ -0,0 +1,250 @@
1
+ import { decodeChunk, pieceLinks } from "./walkFormat2.js";
2
+ import { WalkGraph, byteBudgetError, chunkQueryStart, joinPieces, keptStepsError, lookbackChunks, parseWalkRow, stepBudgetError, walkCut, walkHeader, walkRowName, } from "./walkRows.js";
3
+ // A window reads whole chunks and keeps the steps on it, so a narrow window
4
+ // reads several times what it keeps: 464 haplotypes across the 1.5 kb ABCA7
5
+ // repeat read 5.0 M steps and keep 1.16 M, and the cut takes 0.4 s from warm
6
+ // files. The step budget bounds what the cut keeps, and rows are read to this
7
+ // multiple of it.
8
+ const READ_STEPS_PER_KEPT = 8;
9
+ // A walk the read cuts off goes on in the chunk its row names, out to which a
10
+ // cut reads on, in at most this many rounds and this many chunks from the read
11
+ const FOLLOW_ROUNDS = 3;
12
+ const FOLLOW_CHUNKS = 4;
13
+ // bp of reference a cut keeps past each end of the window
14
+ const WALK_CONTEXT = 1000;
15
+ // @gmod/tabix loops forever on a NaN end and reads nothing for a NaN start
16
+ // (GMOD/tabix-js#157), so every query refuses a range that is not finite first
17
+ function checkRange(refName, start, end) {
18
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
19
+ throw new Error(`${refName}:${start}-${end} is not a finite range to query the graph's index for`);
20
+ }
21
+ }
22
+ function getLines(file, refName, start, end, opts) {
23
+ checkRange(refName, start, end);
24
+ return file.getLines(refName, start, end, opts);
25
+ }
26
+ // Settles like `promise` unless `signal` aborts first
27
+ function raceAbort(promise, signal) {
28
+ if (!signal) {
29
+ return promise;
30
+ }
31
+ signal.throwIfAborted();
32
+ return new Promise((resolve, reject) => {
33
+ const onAbort = () => {
34
+ reject(signal.reason instanceof Error
35
+ ? signal.reason
36
+ : new Error(String(signal.reason)));
37
+ };
38
+ signal.addEventListener('abort', onAbort, { once: true });
39
+ promise.then(resolve, reject).finally(() => {
40
+ signal.removeEventListener('abort', onAbort);
41
+ });
42
+ });
43
+ }
44
+ /**
45
+ * A walk-indexed graph's three files, read a window at a time: one read of
46
+ * each over whole chunks, from the lookback chunks before the window to its
47
+ * end. The indexes first estimate the bytes those reads fetch, and a window
48
+ * past the byte budget reads no row. Only the walks `keep` accepts are
49
+ * decoded. The step budget bounds the steps the cut keeps, and rows holding
50
+ * READ_STEPS_PER_KEPT times as many stop the reads, which go out together.
51
+ */
52
+ export class WalkReader {
53
+ constructor(files, chunkWithoutHeader = 65536) {
54
+ this.files = files;
55
+ this.chunkWithoutHeader = chunkWithoutHeader;
56
+ }
57
+ // The header, read once for every caller: one caller giving up stops only
58
+ // its own wait, and a read that fails is tried again by the next caller
59
+ header(opts = {}) {
60
+ if (!this.headerRead) {
61
+ const read = this.files.walks.getHeaderLines().then(lines => {
62
+ var _a;
63
+ const header = walkHeader(lines);
64
+ const chunk = (_a = header.chunk) !== null && _a !== void 0 ? _a : this.chunkWithoutHeader;
65
+ return {
66
+ ...header,
67
+ chunk,
68
+ lookback: lookbackChunks(header.maxNode, chunk),
69
+ };
70
+ });
71
+ read.catch(() => {
72
+ if (this.headerRead === read) {
73
+ this.headerRead = undefined;
74
+ }
75
+ });
76
+ this.headerRead = read;
77
+ }
78
+ return raceAbort(this.headerRead, opts.signal);
79
+ }
80
+ // The reference sequences the walk file holds windows on
81
+ referenceSequences(opts = {}) {
82
+ return this.files.walks.getReferenceSequenceNames(opts);
83
+ }
84
+ async queryStart(start, opts = {}) {
85
+ const { chunk, lookback } = await this.header(opts);
86
+ return chunkQueryStart(start, chunk, lookback);
87
+ }
88
+ async cut(refName, region, opts = {}) {
89
+ const { signal, keep, context = WALK_CONTEXT, stepBudget = Infinity, byteBudget = Infinity, } = opts;
90
+ const { chunk, lookback, format } = await this.header({ signal });
91
+ // format 2 holds the whole cut in its walk file
92
+ const format2 = format === 2;
93
+ const nodeFile = this.files.nodes;
94
+ const linkFile = opts.links === false ? undefined : this.files.links;
95
+ if (!format2 && !nodeFile) {
96
+ throw new Error('A format 1 walk file needs its node file: open it with `nodes`');
97
+ }
98
+ const links = format2 ? opts.links !== false : linkFile !== undefined;
99
+ const from = chunkQueryStart(region.start, chunk, lookback);
100
+ const files = format2
101
+ ? [this.files.walks]
102
+ : [this.files.walks, nodeFile, ...(linkFile ? [linkFile] : [])];
103
+ const budgeted = Number.isFinite(byteBudget) &&
104
+ files.every(file => file.bytesForRegions !== undefined);
105
+ const bytesFor = async (spans) => {
106
+ if (!budgeted) {
107
+ return 0;
108
+ }
109
+ const sizes = await Promise.all(files.map(file => file.bytesForRegions(spans.map(span => ({ refName, ...span })), { signal })));
110
+ return sizes.reduce((sum, bytes) => sum + bytes, 0);
111
+ };
112
+ const tooLarge = budgeted
113
+ ? await byteBudgetError(async (end) => {
114
+ checkRange(refName, from, end);
115
+ return bytesFor([{ start: from, end }]);
116
+ }, byteBudget, region, chunk, opts.budgetNames)
117
+ : undefined;
118
+ if (tooLarge) {
119
+ throw tooLarge;
120
+ }
121
+ const reads = new AbortController();
122
+ const stop = () => {
123
+ reads.abort(signal === null || signal === void 0 ? void 0 : signal.reason);
124
+ };
125
+ signal === null || signal === void 0 ? void 0 : signal.throwIfAborted();
126
+ signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', stop, { once: true });
127
+ const rows = [];
128
+ let cut;
129
+ const graph = new WalkGraph({
130
+ refName,
131
+ start: region.start,
132
+ end: region.end,
133
+ context,
134
+ sequences: opts.sequences,
135
+ });
136
+ const read = (file, lineCallback, start = from, end = region.end) => getLines(file, refName, start, end, {
137
+ signal: reads.signal,
138
+ lineCallback,
139
+ });
140
+ const addRow = (line) => {
141
+ if (keep === undefined || keep(walkRowName(line))) {
142
+ rows.push(parseWalkRow(line));
143
+ }
144
+ };
145
+ const addNode = (line) => {
146
+ graph.addNode(line);
147
+ };
148
+ const addLink = (line) => {
149
+ graph.addLink(line);
150
+ };
151
+ const decoded = new Set();
152
+ const ends = [];
153
+ // Format 2: each chunk read is decoded once, into the rows, nodes and
154
+ // links format 1 would have filed, and the links between pieces read
155
+ const readChunks = async (start = from, end = region.end) => {
156
+ const byChunk = new Map();
157
+ await read(this.files.walks, line => {
158
+ const cols = line.split('\t');
159
+ const cs = +cols[1];
160
+ const held = byChunk.get(cs);
161
+ if (held) {
162
+ held.push(cols);
163
+ }
164
+ else {
165
+ byChunk.set(cs, [cols]);
166
+ }
167
+ }, start, end);
168
+ for (const [cs, chunkRows] of byChunk) {
169
+ if (decoded.has(cs)) {
170
+ continue;
171
+ }
172
+ decoded.add(cs);
173
+ const chunkCut = decodeChunk(chunkRows, keep);
174
+ rows.push(...chunkCut.rows);
175
+ for (const [id, node] of chunkCut.nodes) {
176
+ graph.placeNode(id, node);
177
+ }
178
+ graph.addFormat2Chunk(chunkCut.chunk);
179
+ ends.push(...chunkCut.ends);
180
+ }
181
+ if (links) {
182
+ for (const [a, b] of pieceLinks(ends)) {
183
+ graph.addLinkEnds(a, b);
184
+ }
185
+ }
186
+ };
187
+ const checkSteps = () => {
188
+ const error = stepBudgetError(rows, stepBudget, READ_STEPS_PER_KEPT, region, chunk, keep !== undefined, opts.budgetNames);
189
+ if (error) {
190
+ throw error;
191
+ }
192
+ };
193
+ try {
194
+ await (format2
195
+ ? readChunks().then(checkSteps)
196
+ : Promise.all([
197
+ read(this.files.walks, addRow).then(checkSteps),
198
+ read(nodeFile, addNode),
199
+ ...(linkFile ? [read(linkFile, addLink)] : []),
200
+ ]));
201
+ // the chunks read, first to last, which a follow widens to the
202
+ // farthest chunk a cut-off walk goes on in, so the reference between
203
+ // is read too
204
+ let first = from;
205
+ let last = Math.floor((region.end - 1) / chunk) * chunk;
206
+ const reach = FOLLOW_CHUNKS * chunk;
207
+ const [lo, hi] = [first - reach, last + reach];
208
+ cut = walkCut(joinPieces(rows), graph.nodes, refName);
209
+ let spent = await bytesFor([{ start: from, end: region.end }]);
210
+ for (let round = 0; round < FOLLOW_ROUNDS; round++) {
211
+ const starts = cut.open
212
+ .filter(c => c.refName === refName && c.start >= lo && c.start <= hi)
213
+ .map(c => c.start);
214
+ const spans = [
215
+ { start: Math.min(first, ...starts), end: first },
216
+ { start: last + chunk, end: Math.max(last, ...starts) + 1 },
217
+ ].filter(span => span.end > span.start);
218
+ if (spans.length === 0) {
219
+ break;
220
+ }
221
+ spent += await bytesFor(spans);
222
+ if (spent > byteBudget) {
223
+ break;
224
+ }
225
+ first = Math.min(first, ...starts);
226
+ last = Math.max(last, ...starts);
227
+ await Promise.all(spans.flatMap(({ start, end }) => format2
228
+ ? [readChunks(start, end)]
229
+ : [
230
+ read(this.files.walks, addRow, start, end),
231
+ read(nodeFile, addNode, start, end),
232
+ ...(linkFile ? [read(linkFile, addLink, start, end)] : []),
233
+ ]));
234
+ cut = walkCut(joinPieces(rows), graph.nodes, refName);
235
+ }
236
+ }
237
+ catch (error) {
238
+ reads.abort();
239
+ throw error;
240
+ }
241
+ finally {
242
+ signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', stop);
243
+ }
244
+ const tooMany = keptStepsError(cut.fragments, stepBudget, region, keep !== undefined);
245
+ if (tooMany) {
246
+ throw tooMany;
247
+ }
248
+ return { graph, ...cut };
249
+ }
250
+ }
@@ -0,0 +1,172 @@
1
+ import { RegionTooLargeError } from './util.ts';
2
+ import type { GraphTables } from './util.ts';
3
+ import type { Format2Chunk } from './walkFormat2.ts';
4
+ export interface WalkRow {
5
+ name: string;
6
+ fragStart: number;
7
+ hapOffset: number;
8
+ piece: number;
9
+ n: number;
10
+ enc: string;
11
+ steps?: {
12
+ ids: Int32Array;
13
+ rev: Uint8Array;
14
+ };
15
+ chunkStart: number;
16
+ prev?: WalkChunk;
17
+ next?: WalkChunk;
18
+ }
19
+ export interface WalkChunk {
20
+ refName: string;
21
+ start: number;
22
+ }
23
+ export interface WalkNode {
24
+ refName: string;
25
+ start: number;
26
+ end: number;
27
+ rank: number;
28
+ onWindow: boolean;
29
+ }
30
+ export interface WalkWindow {
31
+ refName: string;
32
+ start: number;
33
+ end: number;
34
+ context: number;
35
+ sequences?: boolean;
36
+ }
37
+ export declare function walkRowName(line: string): string;
38
+ export declare function parseWalkRow(line: string): WalkRow;
39
+ export interface WalkHeader {
40
+ format?: number;
41
+ chunk?: number;
42
+ maxNode?: number;
43
+ references: string[];
44
+ haplotypes: string[];
45
+ }
46
+ /**
47
+ * What a walk file's header states: its chunk size in a
48
+ * `#walks\tchunk:i:65536` line, from gfa-to-tabix 0.4.0 a
49
+ * `#reference\tGRCh38` line per reference sample and a
50
+ * `#haplotype\tHG002#1` line per other haplotype with rows, and from 0.5.0
51
+ * the longest node in bp as `maxnode:i:1024` beside the chunk size
52
+ */
53
+ export declare function walkHeader(lines: string[]): WalkHeader;
54
+ /**
55
+ * How many chunks before the window's own a cut reads. A node is filed under
56
+ * the chunk holding its start, so a node `maxNode` bp long reaching into the
57
+ * window starts at most ceil(maxNode / chunk) chunks back. At least one, since
58
+ * the chunk before carries the detour a haplotype takes into the window's
59
+ * first reference node; one when the header gives no `maxnode:i:`, which holds
60
+ * for vg's graphs, chopped to nodes of at most 1,024 bp.
61
+ */
62
+ export declare function lookbackChunks(maxNode: number | undefined, chunk: number): number;
63
+ export declare function chunkQueryStart(start: number, chunk: number, lookback?: number): number;
64
+ export declare function walkNameFilter(wanted: string[] | undefined, referenceHaplotype: string | undefined): ((name: string) => boolean) | undefined;
65
+ export declare function decodeSteps(enc: string, n: number, ids?: Int32Array<ArrayBuffer>, rev?: Uint8Array<ArrayBuffer>, at?: number): {
66
+ ids: Int32Array<ArrayBuffer>;
67
+ rev: Uint8Array<ArrayBuffer>;
68
+ };
69
+ export interface WalkRun {
70
+ name: string;
71
+ hapOffset: number;
72
+ ids: Int32Array;
73
+ rev: Uint8Array;
74
+ prev?: WalkChunk;
75
+ next?: WalkChunk;
76
+ }
77
+ export declare function joinPieces(rows: WalkRow[]): WalkRun[];
78
+ export interface WalkFragment {
79
+ name: string;
80
+ hapStart: number;
81
+ hapEnd: number;
82
+ ids: Int32Array;
83
+ rev: Uint8Array;
84
+ }
85
+ /**
86
+ * The cut a window draws from its walks: the reference inside the window plus
87
+ * its context, and each run from its first to its last step on that stretch.
88
+ * A run that leaves the reference there, through steps off it or across a
89
+ * deletion, is followed to the reference step where it rejoins, when the read
90
+ * holds it, and otherwise through the steps off the reference it holds: at
91
+ * the ABCA7 VNTR four haplotypes leave inside the repeat and rejoin 1.8 kb
92
+ * past the window, and cut at the context they measured as partial walks. The reference is cut out as far as those rejoins reach, and
93
+ * a run then runs on outward while the next step's node is already in the
94
+ * cut. Without the run-on a haplotype whose alternate allele straddles the
95
+ * edge stopped short of a node other walks had brought into the cut, and that
96
+ * node drew without it. The run-on adds no node, so one pass is enough, but
97
+ * it reaches through nodes other walks brought in, so with a window edge inside
98
+ * a collapsed array a row's length depends on which haplotypes are cut.
99
+ */
100
+ export declare function walkCut(runs: WalkRun[], nodes: Map<number, WalkNode>, refName: string): {
101
+ kept: Set<number>;
102
+ fragments: WalkFragment[];
103
+ open: WalkChunk[];
104
+ };
105
+ /**
106
+ * The node and link rows a walk cut reads, parsed as they arrive, and the
107
+ * tables of the cut they make with its walks. A node filed under two chunks
108
+ * arrives twice and is read once; columns past a node row's eighth (LN:i:,
109
+ * SQ:Z: and whatever follows) are read only for the bases a window asks for.
110
+ */
111
+ export declare class WalkGraph {
112
+ readonly nodes: Map<number, WalkNode>;
113
+ readonly sequences: Map<number, string>;
114
+ private links;
115
+ private readonly format2;
116
+ private readonly t;
117
+ private readonly window;
118
+ constructor(window: WalkWindow);
119
+ addNode(line: string): void;
120
+ placeNode(id: number, node: Omit<WalkNode, 'onWindow'>): void;
121
+ addLinkEnds(source: number, target: number): void;
122
+ addFormat2Chunk(chunk: Format2Chunk): void;
123
+ addLink(line: string): void;
124
+ /**
125
+ * The cut as typed arrays, which the worker transfers rather than copies:
126
+ * nodes by reference position, each link once in the order its L line
127
+ * sorts, then a walk per fragment, the reference's first and the rest by
128
+ * name. graphTablesGFA writes them as the GFA they stand for, the same bytes
129
+ * for the same rows.
130
+ */
131
+ tables(kept: Set<number>, fragments: WalkFragment[]): GraphTables;
132
+ private referenceFirst;
133
+ }
134
+ /**
135
+ * The zoom-in notice for a cut whose walk rows hold more steps than
136
+ * `readPerKept` times `stepBudget`, or undefined when they fit. A window reads whole chunks, those before it
137
+ * included, so the span that fits is counted in chunks from the window's
138
+ * start; when the chunks a window there always reads are over on their own,
139
+ * no zoom fits and the notice asks for fewer haplotypes. A cut for every
140
+ * haplotype is also offered fewer where the reference and one haplotype, at
141
+ * the walks' average steps, would fit the whole window.
142
+ */
143
+ export interface BudgetNames {
144
+ steps: string;
145
+ bytes: string;
146
+ }
147
+ export declare const BUDGET_NAMES: BudgetNames;
148
+ export declare function stepBudgetError(rows: WalkRow[], stepBudget: number, readPerKept: number, window: {
149
+ start: number;
150
+ end: number;
151
+ }, chunk: number, filtered: boolean, names?: BudgetNames): RegionTooLargeError | undefined;
152
+ /**
153
+ * The zoom-in notice for a cut that keeps more steps than `budget`, or
154
+ * undefined when it fits. The span that fits is the window scaled by how far
155
+ * over the cut is.
156
+ */
157
+ export declare function keptStepsError(fragments: WalkFragment[], budget: number, window: {
158
+ start: number;
159
+ end: number;
160
+ }, filtered: boolean): RegionTooLargeError | undefined;
161
+ /**
162
+ * The zoom-in notice for a cut whose three files would fetch more than
163
+ * `budget` compressed bytes, or undefined when they fit. `bytesTo(end)` is the
164
+ * indexes' estimate for a read from the window's first query base to `end`;
165
+ * the span that fits grows a chunk at a time from the chunk holding the
166
+ * window's start, and when that chunk and those before it are over on their
167
+ * own, no zoom fits.
168
+ */
169
+ export declare function byteBudgetError(bytesTo: (end: number) => Promise<number>, budget: number, window: {
170
+ start: number;
171
+ end: number;
172
+ }, chunk: number, names?: BudgetNames): Promise<RegionTooLargeError | undefined>;