@gmod/gbz-base 1.0.0 → 1.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.
@@ -33,6 +33,23 @@ export class Pager {
33
33
  this.maxBlocks = opts.maxBlocks ?? 256
34
34
  }
35
35
 
36
+ private async read(length: number, start: number) {
37
+ let attempt = 0
38
+ for (;;) {
39
+ try {
40
+ const bytes = await this.source.read(length, start)
41
+ this.bytesFetched += bytes.length
42
+ return bytes
43
+ } catch (error) {
44
+ attempt += 1
45
+ if (attempt >= 3) {
46
+ throw error
47
+ }
48
+ await new Promise(resolve => setTimeout(resolve, 200 * attempt))
49
+ }
50
+ }
51
+ }
52
+
36
53
  seed(index: number, bytes: Uint8Array) {
37
54
  if (
38
55
  bytes.length ===
@@ -51,19 +68,72 @@ export class Pager {
51
68
  }
52
69
  const start = index * this.blockSize
53
70
  const length = Math.min(this.blockSize, this.fileSize - start)
54
- const pending = this.source.read(length, start).then(bytes => {
55
- this.bytesFetched += bytes.length
56
- return bytes
57
- })
71
+ const pending = this.read(length, start)
58
72
  this.fetches += 1
59
73
  this.blocks.set(index, pending)
60
- if (this.blocks.size > this.maxBlocks) {
74
+ this.forgetOnFailure(pending, [index])
75
+ this.evict()
76
+ return pending
77
+ }
78
+
79
+ prefetch(pageNumbers: number[]) {
80
+ const wanted = [
81
+ ...new Set(
82
+ pageNumbers.map(pageNumber =>
83
+ Math.floor(((pageNumber - 1) * this.pageSize) / this.blockSize),
84
+ ),
85
+ ),
86
+ ]
87
+ .filter(index => !this.blocks.has(index))
88
+ .sort((a, b) => a - b)
89
+ let runStart = 0
90
+ while (runStart < wanted.length) {
91
+ let runEnd = runStart + 1
92
+ while (
93
+ runEnd < wanted.length &&
94
+ wanted[runEnd] === wanted[runEnd - 1]! + 1
95
+ ) {
96
+ runEnd += 1
97
+ }
98
+ this.fetchRun(wanted[runStart]!, runEnd - runStart)
99
+ runStart = runEnd
100
+ }
101
+ }
102
+
103
+ private fetchRun(firstIndex: number, count: number) {
104
+ const start = firstIndex * this.blockSize
105
+ const length = Math.min(count * this.blockSize, this.fileSize - start)
106
+ const pending = this.read(length, start)
107
+ this.fetches += 1
108
+ const indexes: number[] = []
109
+ for (let i = 0; i < count; i++) {
110
+ const within = i * this.blockSize
111
+ indexes.push(firstIndex + i)
112
+ this.blocks.set(
113
+ firstIndex + i,
114
+ pending.then(bytes => bytes.subarray(within, within + this.blockSize)),
115
+ )
116
+ }
117
+ this.forgetOnFailure(pending, indexes)
118
+ this.evict()
119
+ }
120
+
121
+ private forgetOnFailure(pending: Promise<Uint8Array>, indexes: number[]) {
122
+ pending.catch(() => {
123
+ for (const index of indexes) {
124
+ this.blocks.delete(index)
125
+ }
126
+ })
127
+ }
128
+
129
+ private evict() {
130
+ while (this.blocks.size > this.maxBlocks) {
61
131
  const oldest = this.blocks.keys().next().value
62
- if (oldest !== undefined) {
63
- this.blocks.delete(oldest)
132
+ if (oldest === undefined) {
133
+ break
64
134
  }
135
+ this.blocks.delete(oldest)
65
136
  }
66
- return pending
67
137
  }
68
138
 
69
139
  async page(pageNumber: number) {
package/src/subgraph.ts CHANGED
@@ -7,12 +7,14 @@ import {
7
7
  entrySide,
8
8
  exitOrientation,
9
9
  exitSide,
10
+ flipNode,
10
11
  flipSide,
11
12
  isReverse,
12
13
  nodeId,
13
14
  nodeOrientation,
14
15
  pathIsCanonical,
15
16
  } from './gbwt/node.ts'
17
+ import { gfaHeaderLines, sha256Hex, subgraphName } from './graphName.ts'
16
18
  import { weightedLcs } from './lcs.ts'
17
19
 
18
20
  import type {
@@ -27,6 +29,13 @@ import type { Pos } from './gbwt/record.ts'
27
29
 
28
30
  export type HaplotypeOutput = 'all' | 'distinct' | 'reference-only' | 'none'
29
31
 
32
+ export type SnarlOutput = 'none' | 'contained' | 'overlapping'
33
+
34
+ type HandleType =
35
+ | { kind: 'snarl-exit'; snarl: [number, number] }
36
+ | { kind: 'chain' }
37
+ | { kind: 'regular' }
38
+
30
39
  export interface PathPosition {
31
40
  seqOffset: number
32
41
  handle: number
@@ -95,31 +104,63 @@ export interface ToJsonOptions {
95
104
  names?: 'anonymous' | 'resolved'
96
105
  }
97
106
 
107
+ function sideBefore(
108
+ a: [number, number, NodeSide],
109
+ b: [number, number, NodeSide],
110
+ ) {
111
+ return (
112
+ a[0] < b[0] ||
113
+ (a[0] === b[0] && (a[1] < b[1] || (a[1] === b[1] && a[2] < b[2])))
114
+ )
115
+ }
116
+
98
117
  class SideQueue {
99
- private items: [number, number, NodeSide][] = []
118
+ private heap: [number, number, NodeSide][] = []
100
119
 
101
120
  push(distance: number, node: number, side: NodeSide) {
102
- this.items.push([distance, node, side])
121
+ const heap = this.heap
122
+ heap.push([distance, node, side])
123
+ let i = heap.length - 1
124
+ while (i > 0) {
125
+ const parent = (i - 1) >> 1
126
+ if (sideBefore(heap[i]!, heap[parent]!)) {
127
+ ;[heap[i], heap[parent]] = [heap[parent]!, heap[i]!]
128
+ i = parent
129
+ } else {
130
+ break
131
+ }
132
+ }
103
133
  }
104
134
 
105
135
  pop() {
106
- let best = 0
107
- for (let i = 1; i < this.items.length; i++) {
108
- const a = this.items[i]!
109
- const b = this.items[best]!
110
- if (
111
- a[0] < b[0] ||
112
- (a[0] === b[0] && (a[1] < b[1] || (a[1] === b[1] && a[2] < b[2])))
113
- ) {
114
- best = i
136
+ const heap = this.heap
137
+ const top = heap[0]
138
+ const last = heap.pop()
139
+ if (heap.length > 0 && last !== undefined) {
140
+ heap[0] = last
141
+ let i = 0
142
+ for (;;) {
143
+ const left = 2 * i + 1
144
+ const right = left + 1
145
+ let smallest = i
146
+ if (left < heap.length && sideBefore(heap[left]!, heap[smallest]!)) {
147
+ smallest = left
148
+ }
149
+ if (right < heap.length && sideBefore(heap[right]!, heap[smallest]!)) {
150
+ smallest = right
151
+ }
152
+ if (smallest === i) {
153
+ break
154
+ }
155
+ ;[heap[i], heap[smallest]] = [heap[smallest]!, heap[i]!]
156
+ i = smallest
115
157
  }
116
158
  }
117
- const [item] = this.items.splice(best, 1)
118
- return item
159
+ return top
119
160
  }
120
161
 
121
162
  get size() {
122
- return this.items.length
163
+ return this.heap.length
123
164
  }
124
165
  }
125
166
 
@@ -394,6 +435,150 @@ export class Subgraph {
394
435
  return { inserted, removed: toRemove.size }
395
436
  }
396
437
 
438
+ async betweenNodes(start: number, end: number) {
439
+ this.clearPaths()
440
+ const active = [start, flipNode(end)]
441
+ const visited = new Set([nodeId(start), nodeId(end)])
442
+ let inserted = 0
443
+ while (active.length > 0) {
444
+ const curr = active.pop()!
445
+ const id = nodeId(curr)
446
+ if (!this.hasNode(id)) {
447
+ await this.addNode(id)
448
+ inserted += 1
449
+ }
450
+ for (const successor of this.record(curr).successors()) {
451
+ const successorId = nodeId(successor)
452
+ if (!visited.has(successorId)) {
453
+ active.push(successor, flipNode(successor))
454
+ visited.add(successorId)
455
+ }
456
+ }
457
+ }
458
+ return inserted
459
+ }
460
+
461
+ async extractSnarls(snarls: SnarlOutput) {
462
+ let inserted = 0
463
+ for (const [start, end] of await this.overlappingSnarls(snarls)) {
464
+ inserted += await this.betweenNodes(start, end)
465
+ }
466
+ return inserted
467
+ }
468
+
469
+ private async overlappingSnarls(snarls: SnarlOutput) {
470
+ const result: [number, number][] = []
471
+ if (snarls !== 'none') {
472
+ let foundLink = false
473
+ for (const handle of this.sortedHandles()) {
474
+ const record = this.record(handle)
475
+ const next = record.next
476
+ if (next !== undefined) {
477
+ foundLink = true
478
+ if (this.hasHandle(next)) {
479
+ if (edgeIsCanonical(handle, next)) {
480
+ result.push([handle, next])
481
+ }
482
+ } else if (
483
+ snarls === 'overlapping' &&
484
+ this.isSnarlEntryInSubgraph(record)
485
+ ) {
486
+ result.push([handle, next])
487
+ }
488
+ }
489
+ }
490
+ if (
491
+ !foundLink &&
492
+ snarls === 'overlapping' &&
493
+ (await this.db.hasChainLinks())
494
+ ) {
495
+ const covering = await this.findCoveringSnarl()
496
+ if (covering) {
497
+ result.push(covering)
498
+ }
499
+ }
500
+ }
501
+ return result
502
+ }
503
+
504
+ private isSnarlEntryInSubgraph(record: GbzRecord) {
505
+ const successors = record.successors()
506
+ const first = successors.find(handle => this.hasHandle(handle))
507
+ return first === undefined
508
+ ? false
509
+ : successors.length > 1 ||
510
+ this.record(flipNode(first)).successors().length > 1
511
+ }
512
+
513
+ private recordReader(onFetch?: () => void) {
514
+ const outside = new Map<number, GbzRecord>()
515
+ return async (handle: number) => {
516
+ const inside = this.records.get(handle)
517
+ if (inside) {
518
+ return inside
519
+ }
520
+ let record = outside.get(handle)
521
+ if (!record) {
522
+ record = await this.db.getRecord(handle)
523
+ onFetch?.()
524
+ if (!record) {
525
+ throw new Error(`Node record ${handle} is missing from the database`)
526
+ }
527
+ outside.set(handle, record)
528
+ }
529
+ return record
530
+ }
531
+ }
532
+
533
+ private async findCoveringSnarl() {
534
+ const read = this.recordReader()
535
+ const isSnarlEntry = async (record: GbzRecord) => {
536
+ const successors = record.successors()
537
+ const first = successors[0]
538
+ return first === undefined
539
+ ? false
540
+ : successors.length > 1 ||
541
+ (await read(flipNode(first))).successors().length > 1
542
+ }
543
+ const classify = async (handle: number): Promise<HandleType> => {
544
+ const reverse = await read(flipNode(handle))
545
+ return reverse.next !== undefined
546
+ ? (await isSnarlEntry(reverse))
547
+ ? { kind: 'snarl-exit', snarl: [flipNode(handle), reverse.next] }
548
+ : { kind: 'chain' }
549
+ : (await read(handle)).next !== undefined
550
+ ? { kind: 'chain' }
551
+ : { kind: 'regular' }
552
+ }
553
+ const visited = new Set<number>()
554
+ const queue = this.sortedHandles().flatMap(handle =>
555
+ this.record(handle).successors(),
556
+ )
557
+ let result: [number, number] | undefined
558
+ let done = false
559
+ while (!done && queue.length > 0) {
560
+ const handle = queue.shift()!
561
+ const id = nodeId(handle)
562
+ if (!this.hasHandle(handle) && !visited.has(id)) {
563
+ visited.add(id)
564
+ const type = await classify(handle)
565
+ if (type.kind === 'snarl-exit') {
566
+ result = type.snarl
567
+ done = true
568
+ } else if (type.kind === 'chain') {
569
+ done = true
570
+ } else {
571
+ for (const orientation of ['forward', 'reverse'] as const) {
572
+ queue.push(
573
+ ...(await read(encodeNode(id, orientation))).successors(),
574
+ )
575
+ }
576
+ }
577
+ }
578
+ }
579
+ return result
580
+ }
581
+
397
582
  extractPaths(reference: ReferencePath | undefined, output: HaplotypeOutput) {
398
583
  this.clearPaths()
399
584
  if (output === 'none') {
@@ -405,72 +590,69 @@ export class Subgraph {
405
590
  const handles = this.sortedHandles()
406
591
  const successors = new Map<
407
592
  number,
408
- { next: Pos; hasPredecessor: boolean }[]
593
+ { nodes: Int32Array; offsets: Int32Array; hasPredecessor: Uint8Array }
409
594
  >()
410
595
  for (const handle of handles) {
411
- successors.set(
412
- handle,
413
- this.record(handle)
414
- .gbwt()
415
- .decompress()
416
- .map(next => ({ next, hasPredecessor: false })),
417
- )
596
+ const { nodes, offsets } = this.record(handle).gbwt().decompressArrays()
597
+ successors.set(handle, {
598
+ nodes,
599
+ offsets,
600
+ hasPredecessor: new Uint8Array(nodes.length),
601
+ })
418
602
  }
419
603
  for (const handle of handles) {
420
- for (const { next } of successors.get(handle) as { next: Pos }[]) {
421
- const entry = successors.get(next.node)?.[next.offset]
604
+ const { nodes, offsets } = successors.get(handle)!
605
+ for (let i = 0; i < nodes.length; i++) {
606
+ const entry = successors.get(nodes[i]!)
422
607
  if (entry) {
423
- entry.hasPredecessor = true
608
+ entry.hasPredecessor[offsets[i]!] = 1
424
609
  }
425
610
  }
426
611
  }
427
612
  let refOffset: number | undefined
428
613
  for (const handle of handles) {
429
- const entries = successors.get(handle) as {
430
- next: Pos
431
- hasPredecessor: boolean
432
- }[]
433
- entries.forEach((entry, offset) => {
434
- if (entry.hasPredecessor) {
435
- return
436
- }
437
- let curr: Pos | undefined = { node: handle, offset }
438
- let isRef = false
439
- const path: number[] = []
440
- const positions: Pos[] = []
441
- let len = 0
442
- while (curr) {
443
- if (
444
- curr.node === refPos?.handle &&
445
- curr.offset === refPos.gbwtOffset
446
- ) {
447
- this.refId = this.paths.length
448
- refOffset = path.length
449
- isRef = true
614
+ const entries = successors.get(handle)!
615
+ for (let offset = 0; offset < entries.nodes.length; offset++) {
616
+ if (entries.hasPredecessor[offset] === 0) {
617
+ let currNode: number | undefined = handle
618
+ let currOffset = offset
619
+ let isRef = false
620
+ const path: number[] = []
621
+ const positions: Pos[] = []
622
+ let len = 0
623
+ while (currNode !== undefined) {
624
+ if (
625
+ currNode === refPos?.handle &&
626
+ currOffset === refPos.gbwtOffset
627
+ ) {
628
+ this.refId = this.paths.length
629
+ refOffset = path.length
630
+ isRef = true
631
+ }
632
+ path.push(currNode)
633
+ positions.push({ node: currNode, offset: currOffset })
634
+ len += this.record(currNode).sequenceLen
635
+ const step = successors.get(currNode)!
636
+ const nextNode: number = step.nodes[currOffset]!
637
+ const nextOffset: number = step.offsets[currOffset]!
638
+ if (nextNode !== ENDMARKER && successors.has(nextNode)) {
639
+ currNode = nextNode
640
+ currOffset = nextOffset
641
+ } else {
642
+ currNode = undefined
643
+ }
644
+ }
645
+ if (isRef || pathIsCanonical(path)) {
646
+ this.paths.push({
647
+ path,
648
+ positions,
649
+ len,
650
+ weight: undefined,
651
+ identity: undefined,
652
+ })
450
653
  }
451
- path.push(curr.node)
452
- positions.push(curr)
453
- len += this.record(curr.node).sequenceLen
454
- const step: { next: Pos } | undefined = successors.get(curr.node)?.[
455
- curr.offset
456
- ]
457
- curr =
458
- step &&
459
- step.next.node !== ENDMARKER &&
460
- successors.has(step.next.node)
461
- ? step.next
462
- : undefined
463
- }
464
- if (isRef || pathIsCanonical(path)) {
465
- this.paths.push({
466
- path,
467
- positions,
468
- len,
469
- weight: undefined,
470
- identity: undefined,
471
- })
472
654
  }
473
- })
655
+ }
474
656
  }
475
657
  if (refPos) {
476
658
  if (refOffset === undefined || this.refId === undefined) {
@@ -551,23 +733,9 @@ export class Subgraph {
551
733
  starts.set(posKey(first), index)
552
734
  }
553
735
  })
554
- const outside = new Map<number, GbzRecord>()
555
- const recordAt = async (handle: number) => {
556
- const inside = this.records.get(handle)
557
- if (inside) {
558
- return inside
559
- }
560
- let record = outside.get(handle)
561
- if (!record) {
562
- record = await this.db.getRecord(handle)
563
- this.stats.identificationFetches += 1
564
- if (!record) {
565
- throw new Error(`Node record ${handle} is missing from the database`)
566
- }
567
- outside.set(handle, record)
568
- }
569
- return record
570
- }
736
+ const recordAt = this.recordReader(() => {
737
+ this.stats.identificationFetches += 1
738
+ })
571
739
  const sampleAt = async (pos: Pos) => {
572
740
  if (pos.node >= minHandle && pos.node <= maxHandle) {
573
741
  return samples.get(posKey(pos))
@@ -957,6 +1125,125 @@ export class Subgraph {
957
1125
  return result
958
1126
  }
959
1127
 
1128
+ private canonicalEdges(id: number) {
1129
+ const edges: [number, number, number][] = []
1130
+ for (const orientation of ['forward', 'reverse'] as const) {
1131
+ const handle = encodeNode(id, orientation)
1132
+ for (const successor of this.record(handle).successors()) {
1133
+ if (this.hasHandle(successor) && edgeIsCanonical(handle, successor)) {
1134
+ edges.push([
1135
+ orientation === 'reverse' ? 1 : 0,
1136
+ nodeId(successor),
1137
+ isReverse(successor) ? 1 : 0,
1138
+ ])
1139
+ }
1140
+ }
1141
+ }
1142
+ edges.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2])
1143
+ return edges.filter(
1144
+ (edge, i) =>
1145
+ i === 0 ||
1146
+ edge[0] !== edges[i - 1]![0] ||
1147
+ edge[1] !== edges[i - 1]![1] ||
1148
+ edge[2] !== edges[i - 1]![2],
1149
+ )
1150
+ }
1151
+
1152
+ async stableName() {
1153
+ const encoder = new TextEncoder()
1154
+ const chunks: Uint8Array[] = []
1155
+ for (const handle of this.sortedHandles()) {
1156
+ if (!isReverse(handle)) {
1157
+ const id = nodeId(handle)
1158
+ let text = `S\t${id}\t${this.record(handle).sequence}\n`
1159
+ for (const [fromReverse, toId, toReverse] of this.canonicalEdges(id)) {
1160
+ text += `L\t${id}\t${fromReverse ? '-' : '+'}\t${toId}\t${toReverse ? '-' : '+'}\n`
1161
+ }
1162
+ chunks.push(encoder.encode(text))
1163
+ }
1164
+ }
1165
+ return sha256Hex(chunks)
1166
+ }
1167
+
1168
+ async toGFA(cigar: boolean, opts: ToJsonOptions = {}) {
1169
+ const lines = [
1170
+ this.refPath ? `H\tVN:Z:1.1\tRS:Z:${this.refPath.sample}` : 'H\tVN:Z:1.1',
1171
+ ...gfaHeaderLines(
1172
+ subgraphName(await this.stableName(), await this.db.graphName()),
1173
+ ),
1174
+ ]
1175
+ const handles = this.sortedHandles()
1176
+ for (const handle of handles) {
1177
+ if (!isReverse(handle)) {
1178
+ lines.push(`S\t${nodeId(handle)}\t${this.record(handle).sequence}`)
1179
+ }
1180
+ }
1181
+ const sign = (handle: number) => (isReverse(handle) ? '-' : '+')
1182
+ for (const handle of handles) {
1183
+ for (const successor of this.record(handle).successors()) {
1184
+ if (this.hasHandle(successor) && edgeIsCanonical(handle, successor)) {
1185
+ lines.push(
1186
+ `L\t${nodeId(handle)}\t${sign(handle)}\t${nodeId(successor)}\t${sign(successor)}\t0M`,
1187
+ )
1188
+ }
1189
+ }
1190
+ }
1191
+ const walk = (
1192
+ info: PathInfo,
1193
+ name: PathName,
1194
+ end: number,
1195
+ cigarString: string | undefined,
1196
+ ) => {
1197
+ const steps = info.path
1198
+ .map(handle => `${isReverse(handle) ? '<' : '>'}${nodeId(handle)}`)
1199
+ .join('')
1200
+ const weight = info.weight === undefined ? '' : `\tWT:i:${info.weight}`
1201
+ const cg = cigarString === undefined ? '' : `\tCG:Z:${cigarString}`
1202
+ return `W\t${name.sample}\t${name.haplotype}\t${name.contig}\t${name.fragment}\t${end}\t${steps}${weight}${cg}`
1203
+ }
1204
+ const contig = this.refPath?.contig ?? 'unknown'
1205
+ if (this.refId !== undefined && this.refPath && this.refInterval) {
1206
+ lines.push(
1207
+ walk(
1208
+ this.paths[this.refId]!,
1209
+ {
1210
+ ...this.refPath,
1211
+ fragment: this.refPath.fragment + this.refInterval[0],
1212
+ },
1213
+ this.refPath.fragment + this.refInterval[1],
1214
+ undefined,
1215
+ ),
1216
+ )
1217
+ }
1218
+ let haplotype = 1
1219
+ this.paths.forEach((info, index) => {
1220
+ if (index !== this.refId) {
1221
+ const resolved = opts.names === 'resolved' ? info.identity : undefined
1222
+ const cigarString = cigar ? this.alignToRef(index) : undefined
1223
+ lines.push(
1224
+ resolved
1225
+ ? walk(
1226
+ info,
1227
+ {
1228
+ ...resolved.name,
1229
+ fragment: resolved.name.fragment + resolved.hapStart,
1230
+ },
1231
+ resolved.name.fragment + resolved.hapEnd,
1232
+ cigarString,
1233
+ )
1234
+ : walk(
1235
+ info,
1236
+ { sample: 'unknown', contig, haplotype, fragment: 0 },
1237
+ info.len,
1238
+ cigarString,
1239
+ ),
1240
+ )
1241
+ haplotype += 1
1242
+ }
1243
+ })
1244
+ return `${lines.join('\n')}\n`
1245
+ }
1246
+
960
1247
  toJSON(cigar: boolean, opts: ToJsonOptions = {}): SubgraphJson {
961
1248
  const handles = this.sortedHandles()
962
1249
  const nodes = handles