@gmod/gbz-base 2.2.0 → 2.3.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/src/subgraph.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  isReverse,
12
12
  nodeId,
13
13
  nodeOrientation,
14
+ pathEndsAreCanonical,
14
15
  pathIsCanonical,
15
16
  } from './gbwt/node.ts'
16
17
  import { gfaHeaderLines, sha256Hex, subgraphName } from './graphName.ts'
@@ -54,7 +55,7 @@ export interface PathIdentity {
54
55
 
55
56
  interface PathInfo {
56
57
  path: number[]
57
- positions: Pos[]
58
+ offsets: number[]
58
59
  len: number
59
60
  weight: number | undefined
60
61
  identity: PathIdentity | undefined
@@ -114,6 +115,24 @@ export interface SubgraphOptions {
114
115
  signal?: AbortSignal | undefined
115
116
  }
116
117
 
118
+ export class SubgraphLimitError extends Error {
119
+ override name = 'SubgraphLimitError'
120
+ readonly limit: number
121
+ readonly windowBp: number | undefined
122
+ readonly walkedBp: number | undefined
123
+
124
+ constructor(limit: number, walk?: { windowBp: number; walkedBp: number }) {
125
+ super(
126
+ walk === undefined
127
+ ? `Subgraph size limit of ${limit} nodes exceeded`
128
+ : `Subgraph size limit of ${limit} nodes exceeded ${walk.walkedBp} bp into a ${walk.windowBp} bp window`,
129
+ )
130
+ this.limit = limit
131
+ this.windowBp = walk?.windowBp
132
+ this.walkedBp = walk?.walkedBp
133
+ }
134
+ }
135
+
117
136
  function sideBefore(
118
137
  a: [number, number, NodeSide],
119
138
  b: [number, number, NodeSide],
@@ -178,26 +197,198 @@ function posKey(pos: Pos) {
178
197
  return `${pos.node}:${pos.offset}`
179
198
  }
180
199
 
200
+ interface FragmentAlignment {
201
+ strand: '+' | '-'
202
+ refStart: number
203
+ refEnd: number
204
+ edits: Edit[]
205
+ weight: number | undefined
206
+ path: number[]
207
+ start: Pos
208
+ identity:
209
+ | {
210
+ pathHandle: number
211
+ name: PathName
212
+ hapStart: number
213
+ hapEnd: number
214
+ walkForward: boolean
215
+ }
216
+ | undefined
217
+ }
218
+
219
+ function joinable(a: FragmentAlignment, b: FragmentAlignment) {
220
+ const insertion = b.identity!.hapStart - a.identity!.hapEnd
221
+ const deletion =
222
+ a.strand === '+' ? b.refStart - a.refEnd : a.refStart - b.refEnd
223
+ return a.strand === b.strand && insertion >= 0 && deletion >= 0
224
+ ? { insertion, deletion }
225
+ : undefined
226
+ }
227
+
228
+ function joinPair(
229
+ a: FragmentAlignment,
230
+ b: FragmentAlignment,
231
+ gap: { insertion: number; deletion: number },
232
+ ): FragmentAlignment {
233
+ const [left, right] = a.strand === '+' ? [a, b] : [b, a]
234
+ const edits = left.edits.map(([op, len]): Edit => [op, len])
235
+ appendGap(edits, gap.insertion, gap.deletion)
236
+ for (const [op, len] of right.edits) {
237
+ appendEdit(edits, op, len)
238
+ }
239
+ const walkForward = a.identity!.walkForward
240
+ const [first, second] = walkForward ? [a, b] : [b, a]
241
+ return {
242
+ strand: a.strand,
243
+ refStart: left.refStart,
244
+ refEnd: right.refEnd,
245
+ edits,
246
+ weight: undefined,
247
+ path: [...first.path, ...second.path],
248
+ start: first.start,
249
+ identity: {
250
+ pathHandle: a.identity!.pathHandle,
251
+ name: a.identity!.name,
252
+ hapStart: a.identity!.hapStart,
253
+ hapEnd: b.identity!.hapEnd,
254
+ walkForward,
255
+ },
256
+ }
257
+ }
258
+
259
+ function joinSiblings(fragments: FragmentAlignment[]) {
260
+ if (fragments.some(f => f.weight !== undefined)) {
261
+ return fragments
262
+ }
263
+ const byPath = new Map<number, number[]>()
264
+ fragments.forEach((fragment, index) => {
265
+ if (fragment.identity) {
266
+ const siblings = byPath.get(fragment.identity.pathHandle)
267
+ if (siblings) {
268
+ siblings.push(index)
269
+ } else {
270
+ byPath.set(fragment.identity.pathHandle, [index])
271
+ }
272
+ }
273
+ })
274
+ const joined = new Map<number, FragmentAlignment>()
275
+ const consumed = new Set<number>()
276
+ for (const siblings of byPath.values()) {
277
+ siblings.sort(
278
+ (x, y) =>
279
+ fragments[x]!.identity!.hapStart - fragments[y]!.identity!.hapStart,
280
+ )
281
+ let head = siblings[0]!
282
+ let current = fragments[head]!
283
+ for (const index of siblings.slice(1)) {
284
+ const next = fragments[index]!
285
+ const gap = joinable(current, next)
286
+ if (gap) {
287
+ current = joinPair(current, next, gap)
288
+ consumed.add(index)
289
+ } else {
290
+ joined.set(head, current)
291
+ head = index
292
+ current = next
293
+ }
294
+ }
295
+ joined.set(head, current)
296
+ }
297
+ return fragments.flatMap((fragment, index) =>
298
+ consumed.has(index) ? [] : [joined.get(index) ?? fragment],
299
+ )
300
+ }
301
+
302
+ function pathPosition(info: PathInfo, k: number): Pos {
303
+ return { node: info.path[k]!, offset: info.offsets[k]! }
304
+ }
305
+
306
+ const SCAN_GAP = 4096
307
+
308
+ function handleRuns(sortedHandles: number[]) {
309
+ const runs: [number, number][] = []
310
+ for (const handle of sortedHandles) {
311
+ const last = runs[runs.length - 1]
312
+ if (last && handle - last[1] <= SCAN_GAP) {
313
+ last[1] = handle
314
+ } else {
315
+ runs.push([handle, handle])
316
+ }
317
+ }
318
+ return runs
319
+ }
320
+
181
321
  interface Anchor {
182
322
  pathHandle: number
183
323
  orientation: Orientation
184
324
  base: number
185
325
  }
186
326
 
327
+ export type ChainEnd =
328
+ | 'in-fragment sample'
329
+ | 'identified sibling'
330
+ | 'out-of-window sample'
331
+ | 'bound'
332
+ | 'endmarker'
333
+ | 'cycle'
334
+
335
+ export interface ChainRecord {
336
+ fragments: number
337
+ steps: number
338
+ seeks: number
339
+ reentries: number
340
+ twinLandings: number
341
+ end: ChainEnd
342
+ pathHandle: number | undefined
343
+ }
344
+
345
+ export interface IdentificationStats {
346
+ interval: number
347
+ scans: [number, number][]
348
+ windowSamples: number
349
+ fragmentLengths: number[]
350
+ companionSeeks: number
351
+ companionMisses: number
352
+ graphLookups: number
353
+ graphFetches: number
354
+ chains: ChainRecord[]
355
+ }
356
+
357
+ interface SubgraphStats {
358
+ orderedAlignments: number
359
+ lcsAlignments: number
360
+ identificationSteps: number
361
+ identificationFetches: number
362
+ identification: IdentificationStats
363
+ }
364
+
187
365
  export class Subgraph {
188
366
  private records = new Map<number, GbzRecord>()
189
367
  private paths: PathInfo[] = []
368
+ private twinStarts = new Set<string>()
190
369
  private refId: number | undefined
191
370
  private refPath: PathName | undefined
192
371
  private refHandle: number | undefined
193
372
  private refInterval: [number, number] | undefined
194
373
  private refIndexCache: Map<number, number[]> | undefined
195
374
  private refPrefixCache: number[] | undefined
196
- readonly stats = {
375
+ private walkedBp: number | undefined
376
+ readonly stats: SubgraphStats = {
197
377
  orderedAlignments: 0,
198
378
  lcsAlignments: 0,
199
379
  identificationSteps: 0,
200
380
  identificationFetches: 0,
381
+ identification: {
382
+ interval: 0,
383
+ scans: [],
384
+ windowSamples: 0,
385
+ fragmentLengths: [],
386
+ companionSeeks: 0,
387
+ companionMisses: 0,
388
+ graphLookups: 0,
389
+ graphFetches: 0,
390
+ chains: [],
391
+ },
201
392
  }
202
393
 
203
394
  private db: GBZBase
@@ -251,7 +442,7 @@ export class Subgraph {
251
442
  private async addNode(id: number) {
252
443
  this.signal?.throwIfAborted()
253
444
  if (this.limit !== undefined && this.nodeCount >= this.limit) {
254
- throw new Error(`Subgraph size limit of ${this.limit} nodes exceeded`)
445
+ throw new SubgraphLimitError(this.limit)
255
446
  }
256
447
  const forward = await this.db.getRecord(encodeNode(id, 'forward'))
257
448
  const reverse = await this.db.getRecord(encodeNode(id, 'reverse'))
@@ -270,6 +461,7 @@ export class Subgraph {
270
461
 
271
462
  private clearPaths() {
272
463
  this.paths = []
464
+ this.twinStarts.clear()
273
465
  this.refId = undefined
274
466
  this.refPath = undefined
275
467
  this.refHandle = undefined
@@ -350,10 +542,44 @@ export class Subgraph {
350
542
  return this.insertContext(active, context)
351
543
  }
352
544
 
545
+ async prefetchReferenceWalk(reference: ReferencePath, len: number) {
546
+ const last = await this.db.indexedPosition(
547
+ reference.handle,
548
+ reference.position.seqOffset + len,
549
+ )
550
+ if (last) {
551
+ const a = reference.position.handle
552
+ const b = last.pos.node
553
+ await this.db.prefetchRecords(Math.min(a, b), Math.max(a, b) + 1)
554
+ }
555
+ }
556
+
353
557
  async aroundInterval(start: PathPosition, len: number, context: number) {
354
558
  if (len === 0) {
355
559
  throw new Error('Interval length must be greater than 0')
356
560
  }
561
+ this.walkedBp = 0
562
+ try {
563
+ return await this.walkInterval(start, len, context)
564
+ } catch (error) {
565
+ throw error instanceof SubgraphLimitError && error.windowBp === undefined
566
+ ? new SubgraphLimitError(error.limit, {
567
+ windowBp: len,
568
+ walkedBp: this.walkedBp,
569
+ })
570
+ : error
571
+ }
572
+ }
573
+
574
+ get referenceWalkedBp() {
575
+ return this.walkedBp
576
+ }
577
+
578
+ private async walkInterval(
579
+ start: PathPosition,
580
+ len: number,
581
+ context: number,
582
+ ) {
357
583
  let pos: Pos = { node: start.handle, offset: start.gbwtOffset }
358
584
  let offset = start.nodeOffset
359
585
  let remaining = len
@@ -361,6 +587,7 @@ export class Subgraph {
361
587
  for (;;) {
362
588
  const id = nodeId(pos.node)
363
589
  const orientation = nodeOrientation(pos.node)
590
+ this.walkedBp = len - remaining
364
591
  await this.ensureNode(id)
365
592
  const record = this.record(pos.node)
366
593
  if (offset >= record.sequenceLen) {
@@ -389,6 +616,7 @@ export class Subgraph {
389
616
  offset = 0
390
617
  remaining -= distanceToNext
391
618
  }
619
+ this.walkedBp = len
392
620
  return this.insertContext(active, context)
393
621
  }
394
622
 
@@ -602,69 +830,133 @@ export class Subgraph {
602
830
  this.refPath = reference?.name
603
831
  this.refHandle = reference?.handle
604
832
  const handles = this.sortedHandles()
605
- const successors = new Map<
606
- number,
607
- { nodes: Int32Array; offsets: Int32Array; hasPredecessor: Uint8Array }
608
- >()
609
- for (const handle of handles) {
610
- const { nodes, offsets } = this.record(handle).gbwt().decompressArrays()
611
- successors.set(handle, {
612
- nodes,
833
+ const count = handles.length
834
+ const indexOf = new Map<number, number>()
835
+ handles.forEach((handle, i) => indexOf.set(handle, i))
836
+ const nextIndex: Int32Array[] = []
837
+ const nextOffset: Int32Array[] = []
838
+ const hasPredecessor: Uint8Array[] = []
839
+ const seqLen = new Int32Array(count)
840
+ for (let i = 0; i < count; i++) {
841
+ const record = this.record(handles[i]!)
842
+ const { nodes, offsets } = record.gbwt().decompressArrays()
843
+ seqLen[i] = record.sequenceLen
844
+ nextIndex.push(nodes)
845
+ nextOffset.push(offsets)
846
+ hasPredecessor.push(new Uint8Array(nodes.length))
847
+ }
848
+ for (let i = 0; i < count; i++) {
849
+ const nodes = nextIndex[i]!
850
+ const offsets = nextOffset[i]!
851
+ for (let k = 0; k < nodes.length; k++) {
852
+ const j = indexOf.get(nodes[k]!)
853
+ if (j === undefined) {
854
+ nodes[k] = -1
855
+ } else {
856
+ nodes[k] = j
857
+ hasPredecessor[j]![offsets[k]!] = 1
858
+ }
859
+ }
860
+ }
861
+ const refIndex =
862
+ refPos === undefined ? undefined : indexOf.get(refPos.handle)
863
+ const refGbwtOffset = refPos?.gbwtOffset
864
+ let refOffset: number | undefined
865
+ const walk = (i: number, offset: number, path: number[] | undefined) => {
866
+ const offsets: number[] = []
867
+ let steps = 0
868
+ let len = 0
869
+ let refAt = -1
870
+ let cur = i
871
+ let off = offset
872
+ for (;;) {
873
+ if (cur === refIndex && off === refGbwtOffset) {
874
+ refAt = steps
875
+ }
876
+ if (path) {
877
+ path.push(handles[cur]!)
878
+ offsets.push(off)
879
+ }
880
+ steps += 1
881
+ len += seqLen[cur]!
882
+ const next = nextIndex[cur]![off]!
883
+ if (next < 0) {
884
+ break
885
+ }
886
+ off = nextOffset[cur]![off]!
887
+ cur = next
888
+ }
889
+ return { offsets, len, refAt, last: cur }
890
+ }
891
+ const keep = (
892
+ path: number[],
893
+ offsets: number[],
894
+ len: number,
895
+ refAt: number,
896
+ ) => {
897
+ if (refAt >= 0) {
898
+ this.refId = this.paths.length
899
+ refOffset = refAt
900
+ }
901
+ this.paths.push({
902
+ path,
613
903
  offsets,
614
- hasPredecessor: new Uint8Array(nodes.length),
904
+ len,
905
+ weight: undefined,
906
+ identity: undefined,
615
907
  })
616
908
  }
617
- for (const handle of handles) {
618
- const { nodes, offsets } = successors.get(handle)!
619
- for (let i = 0; i < nodes.length; i++) {
620
- const entry = successors.get(nodes[i]!)
621
- if (entry) {
622
- entry.hasPredecessor[offsets[i]!] = 1
909
+ const twinsExpected = new Int32Array(count)
910
+ const refMayStartReversed =
911
+ refIndex !== undefined && isReverse(handles[refIndex]!)
912
+ for (let i = 0; i < count; i++) {
913
+ const first = handles[i]!
914
+ if (!isReverse(first)) {
915
+ const starts = hasPredecessor[i]!
916
+ for (let offset = 0; offset < starts.length; offset++) {
917
+ if (starts[offset] === 0) {
918
+ const path: number[] = []
919
+ const { offsets, len, refAt, last } = walk(i, offset, path)
920
+ const lastHandle = handles[last]!
921
+ if (refAt >= 0 || pathEndsAreCanonical(first, lastHandle)) {
922
+ keep(path, offsets, len, refAt)
923
+ if (!isReverse(lastHandle)) {
924
+ const twinRecord = indexOf.get(flipNode(lastHandle))!
925
+ twinsExpected[twinRecord] = twinsExpected[twinRecord]! + 1
926
+ }
927
+ } else {
928
+ this.twinStarts.add(posKey({ node: first, offset }))
929
+ }
930
+ }
623
931
  }
624
932
  }
625
933
  }
626
- let refOffset: number | undefined
627
- for (const handle of handles) {
628
- const entries = successors.get(handle)!
629
- for (let offset = 0; offset < entries.nodes.length; offset++) {
630
- if (entries.hasPredecessor[offset] === 0) {
631
- let currNode: number | undefined = handle
632
- let currOffset = offset
633
- let isRef = false
634
- const path: number[] = []
635
- const positions: Pos[] = []
636
- let len = 0
637
- while (currNode !== undefined) {
934
+ for (let i = 0; i < count; i++) {
935
+ const first = handles[i]!
936
+ if (isReverse(first)) {
937
+ const starts = hasPredecessor[i]!
938
+ let startCount = 0
939
+ for (const flag of starts) {
940
+ if (flag === 0) {
941
+ startCount += 1
942
+ }
943
+ }
944
+ const allTwins = !refMayStartReversed && startCount === twinsExpected[i]
945
+ for (let offset = 0; offset < starts.length; offset++) {
946
+ if (starts[offset] === 0) {
947
+ const bare = allTwins ? undefined : walk(i, offset, undefined)
638
948
  if (
639
- currNode === refPos?.handle &&
640
- currOffset === refPos.gbwtOffset
949
+ bare &&
950
+ (bare.refAt >= 0 ||
951
+ pathEndsAreCanonical(first, handles[bare.last]))
641
952
  ) {
642
- this.refId = this.paths.length
643
- refOffset = path.length
644
- isRef = true
645
- }
646
- path.push(currNode)
647
- positions.push({ node: currNode, offset: currOffset })
648
- len += this.record(currNode).sequenceLen
649
- const step = successors.get(currNode)!
650
- const nextNode: number = step.nodes[currOffset]!
651
- const nextOffset: number = step.offsets[currOffset]!
652
- if (nextNode !== ENDMARKER && successors.has(nextNode)) {
653
- currNode = nextNode
654
- currOffset = nextOffset
953
+ const path: number[] = []
954
+ const { offsets, len, refAt } = walk(i, offset, path)
955
+ keep(path, offsets, len, refAt)
655
956
  } else {
656
- currNode = undefined
957
+ this.twinStarts.add(posKey({ node: first, offset }))
657
958
  }
658
959
  }
659
- if (isRef || pathIsCanonical(path)) {
660
- this.paths.push({
661
- path,
662
- positions,
663
- len,
664
- weight: undefined,
665
- identity: undefined,
666
- })
667
- }
668
960
  }
669
961
  }
670
962
  }
@@ -727,35 +1019,65 @@ export class Subgraph {
727
1019
  )
728
1020
  }
729
1021
  const interval = (await this.db.haplotypeSampleInterval()) ?? 4096
730
- const handles = this.sortedHandles()
731
- const minHandle = handles[0]
732
- const maxHandle = handles[handles.length - 1]
733
- if (minHandle === undefined || maxHandle === undefined) {
1022
+ const runs = handleRuns(this.sortedHandles())
1023
+ if (runs.length === 0) {
734
1024
  return
735
1025
  }
736
1026
  const samples = new Map<string, HaplotypeSample>()
737
- for (const sample of await this.db.haplotypeSamplesInRange(
738
- minHandle,
739
- maxHandle,
740
- )) {
741
- samples.set(posKey(sample), sample)
1027
+ for (const [first, last] of runs) {
1028
+ for (const sample of await this.db.haplotypeSamplesInRange(first, last)) {
1029
+ samples.set(posKey(sample), sample)
1030
+ }
1031
+ }
1032
+ const scanned = (handle: number) => {
1033
+ let lo = 0
1034
+ let hi = runs.length - 1
1035
+ while (lo < hi) {
1036
+ const mid = (lo + hi + 1) >> 1
1037
+ if (runs[mid]![0] <= handle) {
1038
+ lo = mid
1039
+ } else {
1040
+ hi = mid - 1
1041
+ }
1042
+ }
1043
+ const run = runs[lo]!
1044
+ return run[0] <= handle && handle <= run[1]
742
1045
  }
743
1046
  const starts = new Map<string, number>()
744
1047
  this.paths.forEach((info, index) => {
745
- const first = info.positions[0]
746
- if (first && index !== this.refId) {
747
- starts.set(posKey(first), index)
1048
+ if (info.path.length > 0 && index !== this.refId) {
1049
+ starts.set(posKey(pathPosition(info, 0)), index)
1050
+ }
1051
+ })
1052
+ const identification = this.stats.identification
1053
+ identification.interval = interval
1054
+ identification.scans = runs
1055
+ identification.windowSamples = samples.size
1056
+ this.paths.forEach((info, index) => {
1057
+ if (index !== this.refId) {
1058
+ identification.fragmentLengths.push(info.len)
748
1059
  }
749
1060
  })
750
- const recordAt = this.recordReader(() => {
1061
+ const readRecord = this.recordReader(() => {
751
1062
  this.stats.identificationFetches += 1
1063
+ identification.graphFetches += 1
752
1064
  })
753
- const sampleAt = async (pos: Pos) => {
754
- if (pos.node >= minHandle && pos.node <= maxHandle) {
1065
+ const recordAt = (handle: number) => {
1066
+ identification.graphLookups += 1
1067
+ return readRecord(handle)
1068
+ }
1069
+ const sampleAt = async (pos: Pos, chain: ChainRecord) => {
1070
+ if (scanned(pos.node)) {
755
1071
  return samples.get(posKey(pos))
756
1072
  }
757
1073
  this.stats.identificationFetches += 1
758
- return this.db.haplotypeSampleAt(pos.node, pos.offset)
1074
+ identification.companionSeeks += 1
1075
+ chain.seeks += 1
1076
+ const sample = await this.db.haplotypeSampleAt(pos.node, pos.offset)
1077
+ if (!sample) {
1078
+ identification.companionMisses += 1
1079
+ }
1080
+ return sample
759
1081
  }
760
1082
  const names = new Map<number, PathName>()
761
1083
  const nameOf = async (pathHandle: number) => {
@@ -809,6 +1131,16 @@ export class Subgraph {
809
1131
  }
810
1132
  const chain: { index: number; startBp: number }[] = []
811
1133
  const visited = new Set<number>()
1134
+ const record: ChainRecord = {
1135
+ fragments: 0,
1136
+ steps: 0,
1137
+ seeks: 0,
1138
+ reentries: 0,
1139
+ twinLandings: 0,
1140
+ end: 'endmarker',
1141
+ pathHandle: undefined,
1142
+ }
1143
+ identification.chains.push(record)
812
1144
  let anchor: Anchor | undefined
813
1145
  let counter = 0
814
1146
  let current: number | undefined = start
@@ -817,13 +1149,16 @@ export class Subgraph {
817
1149
  this.signal?.throwIfAborted()
818
1150
  if (current !== undefined) {
819
1151
  if (visited.has(current)) {
1152
+ record.end = 'cycle'
820
1153
  break
821
1154
  }
822
1155
  visited.add(current)
823
1156
  const info = this.paths[current]!
824
1157
  chain.push({ index: current, startBp: counter })
1158
+ record.fragments += 1
825
1159
  let bp = counter
826
- for (const position of info.positions) {
1160
+ for (let k = 0; k < info.path.length; k++) {
1161
+ const position = pathPosition(info, k)
827
1162
  const sample = samples.get(posKey(position))
828
1163
  const nodeLen = this.record(position.node).sequenceLen
829
1164
  if (sample) {
@@ -834,42 +1169,56 @@ export class Subgraph {
834
1169
  }
835
1170
  counter += info.len
836
1171
  if (anchor) {
1172
+ record.end = 'in-fragment sample'
837
1173
  break
838
1174
  }
839
- const last = info.positions[info.positions.length - 1]!
1175
+ const last = pathPosition(info, info.path.length - 1)
840
1176
  pos = this.record(last.node).gbwt().lf(last.offset)
841
1177
  current = undefined
842
1178
  }
843
1179
  if (pos === undefined || pos.node === ENDMARKER) {
1180
+ record.end = 'endmarker'
844
1181
  break
845
1182
  }
846
- const known = starts.get(posKey(pos))
1183
+ const key = posKey(pos)
1184
+ const known = starts.get(key)
847
1185
  if (known !== undefined) {
848
1186
  const identity = this.paths[known]!.identity
849
1187
  if (identity) {
850
1188
  anchor = anchorFromIdentity(identity, counter)
1189
+ record.end = 'identified sibling'
851
1190
  break
852
1191
  }
853
1192
  current = known
854
1193
  continue
855
1194
  }
856
- const sample = await sampleAt(pos)
857
- const record = await recordAt(pos.node)
1195
+ if (this.records.has(pos.node)) {
1196
+ record.reentries += 1
1197
+ }
1198
+ if (this.twinStarts.has(key)) {
1199
+ record.twinLandings += 1
1200
+ }
1201
+ const sample = await sampleAt(pos, record)
1202
+ const node = await recordAt(pos.node)
858
1203
  if (sample) {
859
- anchor = anchorFromSample(sample, counter, record.sequenceLen)
1204
+ anchor = anchorFromSample(sample, counter, node.sequenceLen)
1205
+ record.end = 'out-of-window sample'
860
1206
  break
861
1207
  }
862
1208
  this.stats.identificationSteps += 1
1209
+ record.steps += 1
863
1210
  if (
864
1211
  counter - (chain[chain.length - 1] as { startBp: number }).startBp >
865
- 4 * interval + 4 * record.sequenceLen
1212
+ 4 * interval + 4 * node.sequenceLen
866
1213
  ) {
1214
+ record.end = 'bound'
867
1215
  break
868
1216
  }
869
- counter += record.sequenceLen
870
- pos = record.gbwt().lf(pos.offset)
1217
+ counter += node.sequenceLen
1218
+ pos = node.gbwt().lf(pos.offset)
871
1219
  }
872
1220
  if (anchor) {
1221
+ record.pathHandle = anchor.pathHandle
873
1222
  const name = await nameOf(anchor.pathHandle)
874
1223
  for (const { index, startBp } of chain) {
875
1224
  const info = this.paths[index]!
@@ -1020,28 +1369,7 @@ export class Subgraph {
1020
1369
  suffix = refLen - prefix
1021
1370
  }
1022
1371
  appendEdit(edits, 'M', prefix)
1023
- const pathMiddle = pathLen - prefix - suffix
1024
- const refMiddle = refLen - prefix - suffix
1025
- if (pathMiddle === 0) {
1026
- appendEdit(edits, 'D', refMiddle)
1027
- } else if (refMiddle === 0) {
1028
- appendEdit(edits, 'I', pathMiddle)
1029
- } else {
1030
- const mismatch = Math.min(pathMiddle, refMiddle)
1031
- const mismatchIndel =
1032
- 4 * mismatch +
1033
- gapPenalty(pathMiddle - mismatch) +
1034
- gapPenalty(refMiddle - mismatch)
1035
- const insertionDeletion = gapPenalty(pathMiddle) + gapPenalty(refMiddle)
1036
- if (mismatchIndel <= insertionDeletion) {
1037
- appendEdit(edits, 'M', mismatch)
1038
- appendEdit(edits, 'I', pathMiddle - mismatch)
1039
- appendEdit(edits, 'D', refMiddle - mismatch)
1040
- } else {
1041
- appendEdit(edits, 'I', pathMiddle)
1042
- appendEdit(edits, 'D', refMiddle)
1043
- }
1044
- }
1372
+ appendGap(edits, pathLen - prefix - suffix, refLen - prefix - suffix)
1045
1373
  appendEdit(edits, 'M', suffix)
1046
1374
  }
1047
1375
 
@@ -1067,22 +1395,37 @@ export class Subgraph {
1067
1395
  ordered ??
1068
1396
  weightedLcs(path, ref, handle => this.record(handle).sequenceLen)[0]
1069
1397
  const edits: Edit[] = []
1398
+ const refPrefix = this.refPrefix(ref)
1399
+ const alignGap = (
1400
+ pathFrom: number,
1401
+ pathTo: number,
1402
+ refFrom: number,
1403
+ refTo: number,
1404
+ ) => {
1405
+ if (pathFrom === pathTo) {
1406
+ appendEdit(edits, 'D', refPrefix[refTo]! - refPrefix[refFrom]!)
1407
+ } else if (refFrom === refTo) {
1408
+ appendEdit(edits, 'I', this.pathLen(path.slice(pathFrom, pathTo)))
1409
+ } else {
1410
+ this.align(
1411
+ path.slice(pathFrom, pathTo),
1412
+ ref.slice(refFrom, refTo),
1413
+ edits,
1414
+ )
1415
+ }
1416
+ }
1070
1417
  let matched = 0
1071
1418
  let pathOffset = 0
1072
1419
  let refOffset = 0
1073
1420
  for (const [nextPath, nextRef] of lcs) {
1074
- this.align(
1075
- path.slice(pathOffset, nextPath),
1076
- ref.slice(refOffset, nextRef),
1077
- edits,
1078
- )
1421
+ alignGap(pathOffset, nextPath, refOffset, nextRef)
1079
1422
  const nodeLen = this.record(path[nextPath]!).sequenceLen
1080
1423
  appendEdit(edits, 'M', nodeLen)
1081
1424
  matched += nodeLen
1082
1425
  pathOffset = nextPath + 1
1083
1426
  refOffset = nextRef + 1
1084
1427
  }
1085
- this.align(path.slice(pathOffset), ref.slice(refOffset), edits)
1428
+ alignGap(pathOffset, path.length, refOffset, ref.length)
1086
1429
  return { edits, matched }
1087
1430
  }
1088
1431
 
@@ -1132,7 +1475,7 @@ export class Subgraph {
1132
1475
  }
1133
1476
  const ref = this.paths[this.refId]!.path
1134
1477
  const refTotal = this.refPrefix(ref)[ref.length]!
1135
- const result: HaplotypeAlignment[] = []
1478
+ const fragments: FragmentAlignment[] = []
1136
1479
  this.paths.forEach((info, index) => {
1137
1480
  if (index === this.refId) {
1138
1481
  return
@@ -1154,38 +1497,44 @@ export class Subgraph {
1154
1497
  const alongReference = identity
1155
1498
  ? (identity.orientation === 'forward') !== flipped
1156
1499
  : !flipped
1157
- const span: AlignmentSpan = {
1500
+ fragments.push({
1158
1501
  strand: alongReference ? '+' : '-',
1159
1502
  refStart: reference.start + leading,
1160
1503
  refEnd: reference.start + refTotal - trailing,
1161
- cigar: edits
1162
- .slice(first, last)
1163
- .map(([op, len]) => `${len}${op}`)
1164
- .join(''),
1504
+ edits: edits.slice(first, last),
1165
1505
  weight: info.weight,
1166
1506
  path: info.path,
1167
- start: info.positions[0]!,
1168
- }
1169
- if (identity) {
1170
- const hapStart = identity.name.fragment + identity.hapStart
1171
- const hapEnd = identity.name.fragment + identity.hapEnd
1172
- result.push({
1173
- ...span,
1174
- resolved: true,
1175
- name: identity.name,
1176
- label: formatPathName(
1177
- { ...identity.name, fragment: hapStart },
1178
- hapEnd,
1179
- ),
1180
- pathHandle: identity.pathHandle,
1181
- hapStart,
1182
- hapEnd,
1183
- })
1184
- } else {
1185
- result.push({ ...span, resolved: false })
1186
- }
1507
+ start: pathPosition(info, 0),
1508
+ identity:
1509
+ identity === undefined
1510
+ ? undefined
1511
+ : {
1512
+ pathHandle: identity.pathHandle,
1513
+ name: identity.name,
1514
+ hapStart: identity.name.fragment + identity.hapStart,
1515
+ hapEnd: identity.name.fragment + identity.hapEnd,
1516
+ walkForward: identity.orientation === 'forward',
1517
+ },
1518
+ })
1519
+ })
1520
+ return joinSiblings(fragments).map(fragment => {
1521
+ const { edits, identity, ...rest } = fragment
1522
+ const span: AlignmentSpan = { ...rest, cigar: cigarOf(edits) }
1523
+ return identity
1524
+ ? {
1525
+ ...span,
1526
+ resolved: true,
1527
+ name: identity.name,
1528
+ label: formatPathName(
1529
+ { ...identity.name, fragment: identity.hapStart },
1530
+ identity.hapEnd,
1531
+ ),
1532
+ pathHandle: identity.pathHandle,
1533
+ hapStart: identity.hapStart,
1534
+ hapEnd: identity.hapEnd,
1535
+ }
1536
+ : { ...span, resolved: false }
1187
1537
  })
1188
- return result
1189
1538
  }
1190
1539
 
1191
1540
  private canonicalEdges(id: number) {
@@ -1416,3 +1765,30 @@ function appendEdit(edits: Edit[], op: EditOp, len: number) {
1416
1765
  function gapPenalty(len: number) {
1417
1766
  return len === 0 ? 0 : 6 + (len - 1)
1418
1767
  }
1768
+
1769
+ function appendGap(edits: Edit[], pathMiddle: number, refMiddle: number) {
1770
+ if (pathMiddle === 0) {
1771
+ appendEdit(edits, 'D', refMiddle)
1772
+ } else if (refMiddle === 0) {
1773
+ appendEdit(edits, 'I', pathMiddle)
1774
+ } else {
1775
+ const mismatch = Math.min(pathMiddle, refMiddle)
1776
+ const mismatchIndel =
1777
+ 4 * mismatch +
1778
+ gapPenalty(pathMiddle - mismatch) +
1779
+ gapPenalty(refMiddle - mismatch)
1780
+ const insertionDeletion = gapPenalty(pathMiddle) + gapPenalty(refMiddle)
1781
+ if (mismatchIndel <= insertionDeletion) {
1782
+ appendEdit(edits, 'M', mismatch)
1783
+ appendEdit(edits, 'I', pathMiddle - mismatch)
1784
+ appendEdit(edits, 'D', refMiddle - mismatch)
1785
+ } else {
1786
+ appendEdit(edits, 'I', pathMiddle)
1787
+ appendEdit(edits, 'D', refMiddle)
1788
+ }
1789
+ }
1790
+ }
1791
+
1792
+ function cigarOf(edits: Edit[]) {
1793
+ return edits.map(([op, len]) => `${len}${op}`).join('')
1794
+ }