@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.
@@ -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>;