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